diff --git a/package.json b/package.json index 6a8e9d44..0add47e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bluebubbles-server", - "version": "1.8.0", + "version": "1.9.0", "description": "BlueBubbles Server is the app that powers the BlueBubbles app ecosystem", "private": true, "workspaces": [ diff --git a/packages/server/appResources/macos/daemons/cloudflared b/packages/server/appResources/macos/daemons/cloudflared index df222b87..0fa52da1 100755 Binary files a/packages/server/appResources/macos/daemons/cloudflared and b/packages/server/appResources/macos/daemons/cloudflared differ diff --git a/packages/server/appResources/macos/daemons/cloudflared.md5 b/packages/server/appResources/macos/daemons/cloudflared.md5 index 1f936867..35401f6e 100644 --- a/packages/server/appResources/macos/daemons/cloudflared.md5 +++ b/packages/server/appResources/macos/daemons/cloudflared.md5 @@ -1 +1 @@ -207ba90ca89c0ea84e0563a8df1906ca \ No newline at end of file +53f39b71c54e08914cd52e561080fb44 \ No newline at end of file diff --git a/packages/server/appResources/private-api/README.md b/packages/server/appResources/private-api/README.md index 79f66056..d60a5839 100644 --- a/packages/server/appResources/private-api/README.md +++ b/packages/server/appResources/private-api/README.md @@ -1,8 +1,8 @@ # Signature Verification -The `BlueBubblesHelper..md5` files within each macOS directory contain a single string that is the Bundle's MD5 hash, per the build type. The hash is a hash of all the file MD5s within the bundle, in the case of the bundle. You can verify the Bundle by running the following command in your macOS terminal. Replacing `/path/to/private/api/folder` with the path to the parent directory of `BlueBubblesHelper.bundle`: +The `Helper.md5` files within each macOS directory contain a single string that is the Bundle's MD5 hash, per the build type. The hash is a hash of all the file MD5s within the bundle, in the case of the bundle. You can verify the Bundle by running the following command in your macOS terminal. Replacing `/path/to/private/api/folder` with the path to the parent directory of `BlueBubblesHelper.bundle`: -`find -s /path/to/private/api/folder/BlueBubblesHelper.bundle -type f -exec md5 {} \; | md5` +`md5 /path/to/private/api/folder/BlueBubblesHelper.dylib` **GitHub Release Reference**: https://github.com/BlueBubblesApp/BlueBubbles-Server-Helper/releases diff --git a/packages/server/appResources/private-api/macos10/BlueBubblesHelper.dylib b/packages/server/appResources/private-api/macos10/BlueBubblesHelper.dylib old mode 100644 new mode 100755 index 811b0e7d..1dbad8ac Binary files a/packages/server/appResources/private-api/macos10/BlueBubblesHelper.dylib and b/packages/server/appResources/private-api/macos10/BlueBubblesHelper.dylib differ diff --git a/packages/server/appResources/private-api/macos10/BlueBubblesHelper.dylib.md5 b/packages/server/appResources/private-api/macos10/BlueBubblesHelper.dylib.md5 index 6b1ca4fb..b72bb39f 100644 --- a/packages/server/appResources/private-api/macos10/BlueBubblesHelper.dylib.md5 +++ b/packages/server/appResources/private-api/macos10/BlueBubblesHelper.dylib.md5 @@ -1 +1 @@ -6141c214546ff852ea0b3f8ee304c893 \ No newline at end of file +58cf00a723822254164bdc9135c9516c \ No newline at end of file diff --git a/packages/server/appResources/private-api/macos11/BlueBubblesFaceTimeHelper.dylib b/packages/server/appResources/private-api/macos11/BlueBubblesFaceTimeHelper.dylib new file mode 100755 index 00000000..3f3af0a1 Binary files /dev/null and b/packages/server/appResources/private-api/macos11/BlueBubblesFaceTimeHelper.dylib differ diff --git a/packages/server/appResources/private-api/macos11/BlueBubblesFaceTimeHelper.dylib.md5 b/packages/server/appResources/private-api/macos11/BlueBubblesFaceTimeHelper.dylib.md5 new file mode 100644 index 00000000..fc69f16a --- /dev/null +++ b/packages/server/appResources/private-api/macos11/BlueBubblesFaceTimeHelper.dylib.md5 @@ -0,0 +1 @@ +12113e1a0008caaae219930a4de02ad5 \ 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 old mode 100644 new mode 100755 index 9c58cca7..ef406196 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/appResources/private-api/macos11/BlueBubblesHelper.dylib.md5 b/packages/server/appResources/private-api/macos11/BlueBubblesHelper.dylib.md5 index 0a8cb501..04b70c90 100644 --- a/packages/server/appResources/private-api/macos11/BlueBubblesHelper.dylib.md5 +++ b/packages/server/appResources/private-api/macos11/BlueBubblesHelper.dylib.md5 @@ -1 +1 @@ -97eca9d3250121347fe5cb2ce70f18ba \ No newline at end of file +7c5f6f04d0d178ffbf2a1ce623b65370 \ No newline at end of file diff --git a/packages/server/appResources/private-api/version.txt b/packages/server/appResources/private-api/version.txt index 927734f1..a6186220 100644 --- a/packages/server/appResources/private-api/version.txt +++ b/packages/server/appResources/private-api/version.txt @@ -1 +1 @@ -0.0.17 \ No newline at end of file +0.0.18 \ No newline at end of file diff --git a/packages/server/package.json b/packages/server/package.json index aa2d92b7..f098bfa9 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@bluebubbles/server", - "version": "1.8.0", + "version": "1.9.0", "main": "./dist/main.js", "license": "Apache-2.0", "author": { @@ -60,7 +60,7 @@ "babel-loader": "^8.1.0", "css-loader": "^3.5.2", "electron": "17.4.11", - "electron-builder": "^22.9.1", + "electron-builder": "^23.0.2", "electron-notarize": "^1.2.1", "electron-rebuild": "^3.2.9", "eslint": "^8.13.0", @@ -107,7 +107,7 @@ "minimist": "^1.2.6", "ngrok": "^4.3.3", "node-forge": "^1.3.1", - "node-mac-contacts": "1.5.0", + "node-mac-contacts": "^1.7.2", "node-mac-permissions": "^2.3.0", "node-typedstream": "^1.4.0", "numeral": "^2.0.6", diff --git a/packages/server/scripts/electron-builder-config.js b/packages/server/scripts/electron-builder-config.js index 05a15243..29e58780 100644 --- a/packages/server/scripts/electron-builder-config.js +++ b/packages/server/scripts/electron-builder-config.js @@ -54,7 +54,7 @@ module.exports = { "NSSystemAdministrationUsageDescription": "BlueBubbles needs access to manage your system", }, "gatekeeperAssess": false, - "minimumSystemVersion": "10.13.0" + "minimumSystemVersion": "10.11.0" }, "dmg": { "sign": false diff --git a/packages/server/scripts/webpack.base.config.js b/packages/server/scripts/webpack.base.config.js index 70cce93a..0928dadd 100644 --- a/packages/server/scripts/webpack.base.config.js +++ b/packages/server/scripts/webpack.base.config.js @@ -13,7 +13,9 @@ module.exports = { resolve: { extensions: [".tsx", ".ts", ".js", ".json"], alias: { - "@server": path.resolve(__dirname, "../src/server") + "@server": path.resolve(__dirname, "../src/server"), + "@windows": path.resolve(__dirname, "../src/windows"), + "@trays": path.resolve(__dirname, "../src/trays") } }, devtool: "source-map", diff --git a/packages/server/src/main.ts b/packages/server/src/main.ts index 85f6fc25..978f5db4 100644 --- a/packages/server/src/main.ts +++ b/packages/server/src/main.ts @@ -1,7 +1,8 @@ import "reflect-metadata"; -import { app, BrowserWindow, Tray, Menu, nativeTheme, shell, HandlerDetails } from "electron"; +import "@server/env"; + +import { app, nativeTheme } from "electron"; import process from "process"; -import path from "path"; import fs from "fs"; import yaml from "js-yaml"; import { FileSystem } from "@server/fileSystem"; @@ -9,8 +10,8 @@ import { ParseArguments } from "@server/helpers/argParser"; import { Server } from "@server"; import { isEmpty, safeTrim } from "@server/helpers/utils"; -import { autoUpdater } from "electron-updater"; -import { SERVER_UPDATE_DOWNLOADING } from "@server/events"; +import { AppWindow } from "@windows/AppWindow"; +import { AppTray } from "@trays/AppTray"; app.commandLine.appendSwitch("in-process-gpu"); @@ -26,14 +27,10 @@ if (fs.existsSync(FileSystem.cfgFile)) { // Parse the CLI args and marge with config args const args = ParseArguments(process.argv); const parsedArgs: Record = { ...cfg, ...args }; - -let win: BrowserWindow; -let oauthWindow: BrowserWindow = null; -let tray: Tray; let isHandlingExit = false; -// Instantiate the server -Server(parsedArgs, win); +// Initialize the server +Server(parsedArgs, null); // Only 1 instance is allowed const gotTheLock = app.requestSingleInstanceLock(); @@ -42,9 +39,9 @@ if (!gotTheLock) { app.exit(0); } else { app.on("second-instance", (_, __, ___) => { - if (win) { - if (win.isMinimized()) win.restore(); - win.focus(); + if (Server().window) { + if (Server().window.isMinimized()) Server().window.restore(); + Server().window.focus(); } }); @@ -76,261 +73,29 @@ const handleExit = async (event: any = null, { exit = true } = {}) => { } }; -const buildTray = () => { - const headless = (Server().repo?.getConfig("headless") as boolean) ?? false; - let updateOpt: any = { - label: "Check for Updates", - type: "normal", - click: async () => { - if (Server()) { - await Server().updater.checkForUpdate({ showNoUpdateDialog: true }); - } - } - }; - - if (Server()?.updater?.hasUpdate ?? false) { - updateOpt = { - label: `Install Update (${Server().updater.updateInfo.updateInfo.version})`, - type: "normal", - click: async () => { - Server().emitMessage(SERVER_UPDATE_DOWNLOADING, null); - await autoUpdater.downloadUpdate(); - } - }; - } - - return Menu.buildFromTemplate([ - { - label: `BlueBubbles Server v${app.getVersion()}`, - enabled: false - }, - { - label: "Open", - type: "normal", - click: () => { - if (win) { - win.show(); - } else { - createWindow(); - } - } - }, - updateOpt, - { - // The checkmark will cover when this is enabled - label: `Headless Mode${headless ? '' : ' (Disabled)'}`, - type: "checkbox", - checked: headless, - click: async () => { - const toggled = !headless; - await Server().repo.setConfig("headless", toggled); - if (!toggled) { - createWindow(); - } else if (toggled && win) { - win.destroy(); - } - } - }, - { - label: "Restart", - type: "normal", - click: () => { - Server().relaunch(); - } - }, - { - type: "separator" - }, - { - label: `Server Address: ${Server().repo?.getConfig("server_address")}`, - enabled: false - }, - { - label: `Socket Connections: ${Server().httpService?.socketServer.sockets.sockets.size ?? 0}`, - enabled: false - }, - { - label: `Caffeinated: ${Server().caffeinate?.isCaffeinated}`, - enabled: false - }, - { - type: "separator" - }, - { - label: "Close", - type: "normal", - click: async () => { - await handleExit(); - } - } - ]); +const createApp = () => { + AppWindow.getInstance().setArguments(parsedArgs).build(); + AppTray.getInstance().setArguments(parsedArgs).setExitHandler(handleExit).build(); }; -const createTray = () => { - let iconPath = path.join(FileSystem.resources, "macos", "icons", "tray-icon-dark.png"); - if (!nativeTheme.shouldUseDarkColors) - iconPath = path.join(FileSystem.resources, "macos", "icons", "tray-icon-light.png"); - - // If the tray is already created, just change the icon color - if (tray) { - tray.setImage(iconPath); - return; - } - - try { - tray = new Tray(iconPath); - tray.setToolTip("BlueBubbles"); - tray.setContextMenu(buildTray()); - - // Rebuild the tray each time it's clicked - tray.on("click", () => { - tray.setContextMenu(buildTray()); - }); - } catch (ex: any) { - Server().log("Failed to load macOS tray entry!", "error"); - Server().log(ex?.message ?? String(ex), "debug"); - } -}; - -const createOauthWindow = async (url: string) => { - // Create new Browser window - if (oauthWindow && !oauthWindow.isDestroyed) oauthWindow.destroy(); - oauthWindow = new BrowserWindow({ - width: 800, - height: 600, - webPreferences: { - nodeIntegration: true, - } - }); - - oauthWindow.loadURL(url); - oauthWindow.webContents.on('did-finish-load', () => { - const url = oauthWindow.webContents.getURL(); - if (url.split('#')[0] !== Server().oauthService?.callbackUrl) return; - - // Extract the token from the URL - const hash = url.split('#')[1]; - const params = new URLSearchParams(hash); - const token = params.get('access_token'); - const expires = params.get('expires_in'); - Server().oauthService.authToken = token; - Server().oauthService.expiresIn = Number.parseInt(expires); - Server().oauthService.handleProjectCreation(); - - // Clear the window data - oauthWindow.close(); - oauthWindow = null; - }); -}; - -const createWindow = async () => { - const headless = (Server().repo?.getConfig("headless") as boolean) ?? false; - if (headless) { - Server().log("Headless mode enabled, skipping window creation..."); - return; - } - - win = new BrowserWindow({ - title: "BlueBubbles Server", - useContentSize: true, - width: 1080, - minWidth: 850, - height: 750, - minHeight: 600, - webPreferences: { - nodeIntegration: true, // Required in new electron version - contextIsolation: false // Required or else we get a `global` is not defined error - } - }); - - if (process.env.NODE_ENV === "development") { - process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = "1"; // eslint-disable-line require-atomic-updates - win.loadURL(`http://localhost:3000`); - } else { - win.loadURL(`file://${path.join(__dirname, "index.html")}`); - } - - win.on("closed", () => { - win = null; - }); - - // Prevent the title from being changed from BlueBubbles - win.on("page-title-updated", evt => { - evt.preventDefault(); - }); - - // Make links open in the browser - win.webContents.setWindowOpenHandler((details: HandlerDetails) => { - if (details.url.startsWith('https://accounts.google.com/o/oauth2/v2/auth')) { - createOauthWindow(details.url); - } else { - shell.openExternal(details.url); - } - - return { action: "deny" }; - }); - - // Hook onto when we load the UI - win.webContents.on("dom-ready", async () => { - Server().uiLoaded = true; - - if (!win.webContents.isDestroyed()) { - win.webContents.send("config-update", Server().repo.config); - } - }); - - // Hook onto when the UI finishes loading - win.webContents.on("did-finish-load", async () => { - Server().uiLoaded = true; - }); - - // Hook onto when the UI fails to load - win.webContents.on( - "did-fail-load", - async (event, errorCode, errorDescription, validatedURL, frameProcessId, frameRoutingId) => { - Server().uiLoaded = false; - Server().log(`Failed to load UI! Error: [${errorCode}] ${errorDescription}`, "error"); - } - ); - - // Hook onto when the renderer process crashes - win.webContents.on("render-process-gone", async (event, details) => { - Server().uiLoaded = false; - Server().log(`Renderer process crashed! Error: [${details.exitCode}] ${details.reason}`, "error"); - }); - - // Hook onto when the webcontents are destroyed - win.webContents.on("destroyed", async () => { - Server().uiLoaded = false; - Server().log(`Webcontents were destroyed.`, "debug"); - }); - - // Hook onto when there is a preload error - win.webContents.on("preload-error", async (event, preloadPath, error) => { - Server().log(`A preload error occurred: Error: ${error.message}.`, "error"); - }); - - // Set the new window in the Server() - Server(parsedArgs, win); -}; - -Server().on('update-available', (_) => { - createTray(); +Server().on("update-available", _ => { + AppTray.getInstance().build(); }); -Server().on('ready', () => { - createWindow(); - createTray(); +Server().on("ready", () => { + createApp(); }); app.on("ready", () => { nativeTheme.on("updated", () => { - createTray(); + AppTray.getInstance().build(); }); }); app.on("activate", () => { - if (win == null && Server().repo) createWindow(); + if (Server().window == null && Server().repo) { + AppWindow.getInstance().build(); + } }); app.on("window-all-closed", () => { diff --git a/packages/server/src/server/api/v1/apple/actions.ts b/packages/server/src/server/api/apple/actions.ts similarity index 99% rename from packages/server/src/server/api/v1/apple/actions.ts rename to packages/server/src/server/api/apple/actions.ts index cf031f64..7a81ce2b 100644 --- a/packages/server/src/server/api/v1/apple/actions.ts +++ b/packages/server/src/server/api/apple/actions.ts @@ -14,8 +14,8 @@ import { restartMessages, openChat, sendMessageFallback -} from "@server/api/v1/apple/scripts"; -import { ValidRemoveTapback } from "../../../types"; +} from "@server/api/apple/scripts"; +import { ValidRemoveTapback } from "../../types"; import { safeExecuteAppleScript, @@ -26,7 +26,7 @@ import { isNotEmpty, isEmpty, safeTrim -} from "../../../helpers/utils"; +} from "../../helpers/utils"; import { tapbackUIMap } from "./mappings"; import { MessageInterface } from "../interfaces/messageInterface"; diff --git a/packages/server/src/server/api/v1/apple/constants.ts b/packages/server/src/server/api/apple/constants.ts similarity index 100% rename from packages/server/src/server/api/v1/apple/constants.ts rename to packages/server/src/server/api/apple/constants.ts diff --git a/packages/server/src/server/api/v1/apple/mappings.ts b/packages/server/src/server/api/apple/mappings.ts similarity index 100% rename from packages/server/src/server/api/v1/apple/mappings.ts rename to packages/server/src/server/api/apple/mappings.ts diff --git a/packages/server/src/server/api/v1/apple/scripts.ts b/packages/server/src/server/api/apple/scripts.ts similarity index 99% rename from packages/server/src/server/api/v1/apple/scripts.ts rename to packages/server/src/server/api/apple/scripts.ts index 17e051ef..5edb666d 100644 --- a/packages/server/src/server/api/v1/apple/scripts.ts +++ b/packages/server/src/server/api/apple/scripts.ts @@ -3,7 +3,8 @@ import macosVersion from "macos-version"; import CompareVersions from "compare-versions"; import { transports } from "electron-log"; import { FileSystem } from "@server/fileSystem"; -import { escapeOsaExp, getiMessageAddressFormat, isEmpty, isMinBigSur, isMinVentura, isNotEmpty } from "@server/helpers/utils"; +import { escapeOsaExp, getiMessageAddressFormat, isEmpty, isNotEmpty } from "@server/helpers/utils"; +import { isMinBigSur, isMinVentura } from "@server/env"; const osVersion = macosVersion(); @@ -90,13 +91,6 @@ export const startMessages = () => { return startApp("Messages"); }; -/** - * The AppleScript used to send a message with or without an attachment - */ -export const stopMessages = () => { - return stopApp("Messages"); -}; - /** * The AppleScript used to hide an app */ diff --git a/packages/server/src/server/services/httpService/api/v1/httpRoutes.ts b/packages/server/src/server/api/http/api/v1/httpRoutes.ts similarity index 80% rename from packages/server/src/server/services/httpService/api/v1/httpRoutes.ts rename to packages/server/src/server/api/http/api/v1/httpRoutes.ts index fc0a54d3..9069d5fa 100644 --- a/packages/server/src/server/services/httpService/api/v1/httpRoutes.ts +++ b/packages/server/src/server/api/http/api/v1/httpRoutes.ts @@ -18,11 +18,11 @@ import { UiRouter } from "./routers/uiRouter"; import { SettingsRouter } from "./routers/settingsRouter"; import { ContactRouter } from "./routers/contactRouter"; import { MetricsMiddleware } from "./middleware/metricsMiddleware"; -import { TimeoutMiddleware } from "./middleware/timeoutMiddleware"; import { LogMiddleware } from "./middleware/logMiddleware"; import { ErrorMiddleware } from "./middleware/errorMiddleware"; import { MacOsRouter } from "./routers/macosRouter"; import { iCloudRouter } from "./routers/icloudRouter"; +import { FaceTimeRouter } from "./routers/facetimeRouter"; import { PrivateApiMiddleware } from "./middleware/privateApiMiddleware"; import { HttpDefinition, HttpMethod, HttpRoute, HttpRouteGroup, KoaMiddleware } from "../../types"; import { SettingsValidator } from "./validators/settingsValidator"; @@ -35,16 +35,21 @@ import { AlertsValidator } from "./validators/alertsValidator"; import { ScheduledMessageValidator } from "./validators/scheduledMessageValidator"; import { ScheduledMessageRouter } from "./routers/scheduledMessageRouter"; import { ThemeValidator } from "./validators/themeValidator"; +import type { Context, Next } from "koa"; export class HttpRoutes { static version = 1; + static defaultRequestTimeout = 60 * 1000; // 1 minute + + static defaultResponseTimeout = 5 * 60 * 1000; // 5 minutes + private static get protected() { return [...HttpRoutes.unprotected, AuthMiddleware]; } private static get unprotected() { - return [MetricsMiddleware, ErrorMiddleware, LogMiddleware, TimeoutMiddleware]; + return [MetricsMiddleware, ErrorMiddleware, LogMiddleware]; } static api: HttpDefinition = { @@ -65,6 +70,7 @@ export class HttpRoutes { name: "macOS", middleware: HttpRoutes.protected, prefix: "mac", + responseTimeoutMs: 30 * 1000, routes: [ { method: HttpMethod.POST, @@ -83,6 +89,24 @@ export class HttpRoutes { middleware: HttpRoutes.protected, prefix: "icloud", routes: [ + { + method: HttpMethod.GET, + path: "account", + middleware: [...HttpRoutes.protected, PrivateApiMiddleware], + controller: iCloudRouter.getAccountInfo, + }, + { + method: HttpMethod.POST, + path: "account/alias", + middleware: [...HttpRoutes.protected, PrivateApiMiddleware], + controller: iCloudRouter.changeAlias + }, + { + method: HttpMethod.GET, + path: "contact", + middleware: [...HttpRoutes.protected, PrivateApiMiddleware], + controller: iCloudRouter.getContactCard + }, { method: HttpMethod.GET, path: "findmy/devices", @@ -96,6 +120,7 @@ export class HttpRoutes { { method: HttpMethod.GET, path: "findmy/friends", + middleware: [...HttpRoutes.protected, PrivateApiMiddleware], controller: iCloudRouter.friends }, { @@ -138,7 +163,9 @@ export class HttpRoutes { { method: HttpMethod.POST, path: "update/install", - controller: ServerRouter.installUpdate + controller: ServerRouter.installUpdate, + // 30 minute timeout in the case that they want to wait for the install to complete + responseTimeoutMs: 30 * 60 * 1000, }, { method: HttpMethod.GET, @@ -201,13 +228,28 @@ export class HttpRoutes { path: "upload", middleware: [...HttpRoutes.protected, PrivateApiMiddleware], validators: [AttachmentValidator.validateUpload], - controller: AttachmentRouter.uploadAttachment + controller: AttachmentRouter.uploadAttachment, + // 30 minute timeout for uploads + requestTimeoutMs: 30 * 60 * 1000, }, { method: HttpMethod.GET, path: ":guid/download", validators: [AttachmentValidator.validateDownload], - controller: AttachmentRouter.download + controller: AttachmentRouter.download, + // 30 minute timeout for this to account for people on slow connections + responseTimeoutMs: 30 * 60 * 1000, + }, + { + method: HttpMethod.GET, + path: ":guid/download/force", + middleware: [...HttpRoutes.protected, PrivateApiMiddleware], + validators: [AttachmentValidator.validateDownload], + controller: AttachmentRouter.forceDownload, + // 60 minute timeout for this to account for people on slow connections. + // Since a "force" means the attachment isnt already downloaded, give it + // double the time. The client can timeout if it pleases. + responseTimeoutMs: 60 * 60 * 1000, }, { method: HttpMethod.GET, @@ -218,7 +260,9 @@ export class HttpRoutes { { method: HttpMethod.GET, path: ":guid/live", - controller: AttachmentRouter.downloadLive + controller: AttachmentRouter.downloadLive, + // 30 minute timeout for this to account for people on slow connections + responseTimeoutMs: 30 * 60 * 1000, }, { method: HttpMethod.GET, @@ -257,6 +301,18 @@ export class HttpRoutes { validators: [ChatValidator.validateGetMessages], controller: ChatRouter.getMessages }, + { + method: HttpMethod.GET, + path: ":guid/share/contact/status", + middleware: [...HttpRoutes.protected, PrivateApiMiddleware], + controller: ChatRouter.shouldShareContact + }, + { + method: HttpMethod.POST, + path: ":guid/share/contact", + middleware: [...HttpRoutes.protected, PrivateApiMiddleware], + controller: ChatRouter.shareContact + }, { method: HttpMethod.POST, path: ":guid/read", @@ -320,7 +376,7 @@ export class HttpRoutes { path: ":guid/icon", middleware: [...HttpRoutes.protected, PrivateApiMiddleware], validators: [ChatValidator.validateGroupChatIcon], - controller: ChatRouter.setGroupChatIcon + controller: ChatRouter.setGroupChatIcon, }, { method: HttpMethod.DELETE, @@ -516,6 +572,28 @@ export class HttpRoutes { } ] }, + // { + // name: "FaceTime", + // middleware: [...HttpRoutes.protected, PrivateApiMiddleware], + // prefix: "facetime", + // routes: [ + // { + // method: HttpMethod.POST, + // path: "session", + // controller: FaceTimeRouter.newSession + // }, + // { + // method: HttpMethod.POST, + // path: "answer/:call_uuid", + // controller: FaceTimeRouter.answer + // }, + // { + // method: HttpMethod.POST, + // path: "leave/:call_uuid", + // controller: FaceTimeRouter.leave + // }, + // ] + // }, { name: "Contact", middleware: HttpRoutes.protected, @@ -529,7 +607,9 @@ export class HttpRoutes { { method: HttpMethod.POST, path: "", - controller: ContactRouter.create + controller: ContactRouter.create, + // Increase the timeout to 5 minutes for requests in case there are avatars + requestTimeoutMs: 5 * 60 * 1000, }, { method: HttpMethod.POST, @@ -625,8 +705,62 @@ export class HttpRoutes { } } + private static TimeoutMiddleware(requestTimeoutMs: number, responseTimeoutMs: number) { + return async (ctx: Context, next: Next) => { + + ctx.req.setTimeout(requestTimeoutMs, () => { + ctx.status = 504; + ctx.set("Content-Type", "application/json"); + ctx.res.end(JSON.stringify({ + status: 504, + message: `The request timed-out after ${requestTimeoutMs} ms!`, + error: { + type: "Gateway Timeout", + message: "The data in your request took too long to get to the server!" + } + })); + }); + + ctx.res.setTimeout(responseTimeoutMs, () => { + ctx.status = 504; + ctx.set("Content-Type", "application/json"); + ctx.res.end(JSON.stringify({ + status: 504, + message: `The request timed-out after ${responseTimeoutMs}!`, + error: { + type: "Gateway Timeout", + message: "The server took too long to respond and has timed-out!" + } + })); + }); + + await next(); + } + }; + private static buildMiddleware(group: HttpRouteGroup, route: HttpRoute) { - return [...(route?.middleware ?? group.middleware ?? []), ...(route.validators ?? []), route.controller]; + let reqTimeout = this.defaultRequestTimeout; + let resTimeout = this.defaultResponseTimeout; + + // Prioritize the route timeout over the group timeout + if (route.requestTimeoutMs && route.requestTimeoutMs > 0) { + reqTimeout = route.requestTimeoutMs; + } else if (group.requestTimeoutMs && group.requestTimeoutMs > 0) { + reqTimeout = group.requestTimeoutMs; + } + + // Prioritize the route timeout over the group timeout + if (route.responseTimeoutMs && route.responseTimeoutMs > 0) { + resTimeout = route.responseTimeoutMs; + } else if (group.responseTimeoutMs && group.responseTimeoutMs > 0) { + resTimeout = group.responseTimeoutMs; + } + + return [ + ...(route?.middleware ?? group.middleware ?? []), + this.TimeoutMiddleware(reqTimeout, resTimeout), + ...(route.validators ?? []), route.controller + ]; } private static registerRoute( diff --git a/packages/server/src/server/services/httpService/api/v1/middleware/authMiddleware.ts b/packages/server/src/server/api/http/api/v1/middleware/authMiddleware.ts similarity index 100% rename from packages/server/src/server/services/httpService/api/v1/middleware/authMiddleware.ts rename to packages/server/src/server/api/http/api/v1/middleware/authMiddleware.ts diff --git a/packages/server/src/server/services/httpService/api/v1/middleware/errorMiddleware.ts b/packages/server/src/server/api/http/api/v1/middleware/errorMiddleware.ts similarity index 93% rename from packages/server/src/server/services/httpService/api/v1/middleware/errorMiddleware.ts rename to packages/server/src/server/api/http/api/v1/middleware/errorMiddleware.ts index 1c41fb2e..20c8a7fc 100644 --- a/packages/server/src/server/services/httpService/api/v1/middleware/errorMiddleware.ts +++ b/packages/server/src/server/api/http/api/v1/middleware/errorMiddleware.ts @@ -1,6 +1,6 @@ import { Context, Next } from "koa"; import { Server } from "@server"; -import { ErrorTypes } from "@server/services/httpService/api/v1/responses/types"; +import { ErrorTypes } from "@server/api/http/api/v1/responses/types"; import { HTTPError } from "../responses/errors"; import { createServerErrorResponse } from "../responses"; diff --git a/packages/server/src/server/services/httpService/api/v1/middleware/logMiddleware.ts b/packages/server/src/server/api/http/api/v1/middleware/logMiddleware.ts similarity index 100% rename from packages/server/src/server/services/httpService/api/v1/middleware/logMiddleware.ts rename to packages/server/src/server/api/http/api/v1/middleware/logMiddleware.ts diff --git a/packages/server/src/server/services/httpService/api/v1/middleware/metricsMiddleware.ts b/packages/server/src/server/api/http/api/v1/middleware/metricsMiddleware.ts similarity index 100% rename from packages/server/src/server/services/httpService/api/v1/middleware/metricsMiddleware.ts rename to packages/server/src/server/api/http/api/v1/middleware/metricsMiddleware.ts diff --git a/packages/server/src/server/services/httpService/api/v1/middleware/privateApiMiddleware.ts b/packages/server/src/server/api/http/api/v1/middleware/privateApiMiddleware.ts similarity index 100% rename from packages/server/src/server/services/httpService/api/v1/middleware/privateApiMiddleware.ts rename to packages/server/src/server/api/http/api/v1/middleware/privateApiMiddleware.ts diff --git a/packages/server/src/server/services/httpService/api/v1/responses/errors.ts b/packages/server/src/server/api/http/api/v1/responses/errors.ts similarity index 87% rename from packages/server/src/server/services/httpService/api/v1/responses/errors.ts rename to packages/server/src/server/api/http/api/v1/responses/errors.ts index f7f3a201..0bc07fbb 100644 --- a/packages/server/src/server/services/httpService/api/v1/responses/errors.ts +++ b/packages/server/src/server/api/http/api/v1/responses/errors.ts @@ -86,6 +86,20 @@ export class ServerError extends HTTPError { } } +export class GatewayTimeout extends HTTPError { + constructor(response?: ResponseParams) { + super({ + status: 504, + message: response?.message ?? `The server took too long to response!`, + error: { + type: ErrorTypes.GATEWAY_TIMEOUT, + message: response?.error ?? ResponseMessages.GATEWAY_TIMEOUT + }, + data: response?.data + }); + } +} + export class IMessageError extends HTTPError { constructor(response?: ResponseParams) { super({ diff --git a/packages/server/src/server/services/httpService/api/v1/responses/index.ts b/packages/server/src/server/api/http/api/v1/responses/index.ts similarity index 96% rename from packages/server/src/server/services/httpService/api/v1/responses/index.ts rename to packages/server/src/server/api/http/api/v1/responses/index.ts index f6b4be19..fec3c2c3 100644 --- a/packages/server/src/server/services/httpService/api/v1/responses/index.ts +++ b/packages/server/src/server/api/http/api/v1/responses/index.ts @@ -3,7 +3,7 @@ import { ResponseMessages, ResponseData, ErrorTypes -} from "@server/services/httpService/api/v1/responses/types"; +} from "@server/api/http/api/v1/responses/types"; export const createSuccessResponse = ( data: ResponseData, diff --git a/packages/server/src/server/services/httpService/api/v1/responses/success.ts b/packages/server/src/server/api/http/api/v1/responses/success.ts similarity index 87% rename from packages/server/src/server/services/httpService/api/v1/responses/success.ts rename to packages/server/src/server/api/http/api/v1/responses/success.ts index 80b5726c..51dde61c 100644 --- a/packages/server/src/server/services/httpService/api/v1/responses/success.ts +++ b/packages/server/src/server/api/http/api/v1/responses/success.ts @@ -31,8 +31,8 @@ export class HTTPResponse { // Reload the data with conditional keys const res = response as ResponseParams; this.response = { status, message: res?.message ?? "No Message Response" }; - if (res?.data) this.response.data = res.data; - if (res?.metadata) this.response.metadata = res.metadata; + if (res?.data !== undefined) this.response.data = res.data; + if (res?.metadata !== undefined) this.response.metadata = res.metadata; } else if (responseType === "html") { this.response = response as string; } else if (responseType === "file") { @@ -66,8 +66,8 @@ export class HTTPResponse { export class Success extends HTTPResponse { constructor(ctx: RouterContext, response: ResponseParams) { const data: ResponseParams = { message: response?.message ?? ResponseMessages.SUCCESS }; - if (response?.data) data.data = response.data; - if (response?.metadata) data.metadata = response.metadata; + if (response?.data !== undefined) data.data = response.data; + if (response?.metadata !== undefined) data.metadata = response.metadata; super(ctx, 200, data); } } @@ -89,8 +89,6 @@ export class HTML extends HTTPResponse { export class NoData extends HTTPResponse { constructor(ctx: RouterContext, response: ResponseParams) { const data: ResponseParams = { message: response?.message ?? ResponseMessages.NO_DATA }; - if (response?.data) data.data = response.data; - if (response?.metadata) data.metadata = response.metadata; super(ctx, 201, data); } } diff --git a/packages/server/src/server/services/httpService/api/v1/responses/types.ts b/packages/server/src/server/api/http/api/v1/responses/types.ts similarity index 84% rename from packages/server/src/server/services/httpService/api/v1/responses/types.ts rename to packages/server/src/server/api/http/api/v1/responses/types.ts index 736ec3e7..f560d666 100644 --- a/packages/server/src/server/services/httpService/api/v1/responses/types.ts +++ b/packages/server/src/server/api/http/api/v1/responses/types.ts @@ -1,6 +1,6 @@ import * as fs from "fs"; -export type ValidStatuses = 200 | 201 | 400 | 401 | 403 | 404 | 500; +export type ValidStatuses = 200 | 201 | 400 | 401 | 403 | 404 | 500 | 504; export type ResponseData = any; @@ -12,7 +12,8 @@ export enum ResponseMessages { FORBIDDEN = "Forbidden", NO_DATA = "No Data", NOT_FOUND = "Not Found", - UNKNOWN_IMESSAGE_ERROR = "Unknown iMessage Error" + UNKNOWN_IMESSAGE_ERROR = "Unknown iMessage Error", + GATEWAY_TIMEOUT = "Gateway Timeout" } export enum ErrorTypes { @@ -21,7 +22,8 @@ export enum ErrorTypes { IMESSAGE_ERROR = "iMessage Error", SOCKET_ERROR = "Socket Error", VALIDATION_ERROR = "Validation Error", - AUTHENTICATION_ERROR = "Authentication Error" + AUTHENTICATION_ERROR = "Authentication Error", + GATEWAY_TIMEOUT = "Gateway Timeout" } export type ErrorBody = { diff --git a/packages/server/src/server/services/httpService/api/v1/routers/attachmentRouter.ts b/packages/server/src/server/api/http/api/v1/routers/attachmentRouter.ts similarity index 82% rename from packages/server/src/server/services/httpService/api/v1/routers/attachmentRouter.ts rename to packages/server/src/server/api/http/api/v1/routers/attachmentRouter.ts index 886b4d32..c9f4781e 100644 --- a/packages/server/src/server/services/httpService/api/v1/routers/attachmentRouter.ts +++ b/packages/server/src/server/api/http/api/v1/routers/attachmentRouter.ts @@ -7,11 +7,12 @@ import { Server } from "@server"; import { generateMd5Hash } from "@server/utils/CryptoUtils"; import { FileSystem } from "@server/fileSystem"; import { convertAudio, convertImage } from "@server/databases/imessage/helpers/utils"; -import { isEmpty, isTruthyBool } from "@server/helpers/utils"; -import { AttachmentInterface } from "@server/api/v1/interfaces/attachmentInterface"; +import { isEmpty, isTruthyBool, resultAwaiter } from "@server/helpers/utils"; +import { AttachmentInterface } from "@server/api/interfaces/attachmentInterface"; import { FileStream, Success } from "../responses/success"; -import { NotFound, ServerError } from "../responses/errors"; -import { AttachmentSerializer } from "@server/api/v1/serializers/AttachmentSerializer"; +import { BadRequest, NotFound, ServerError } from "../responses/errors"; +import { AttachmentSerializer } from "@server/api/serializers/AttachmentSerializer"; +import { Attachment } from "@server/databases/imessage/entity/Attachment"; export class AttachmentRouter { static async count(ctx: RouterContext, _: Next) { @@ -168,4 +169,34 @@ export class AttachmentRouter { await AttachmentInterface.upload(attachment.path, hash); return new Success(ctx, { data: { hash } }).send(); } + + static async forceDownload(ctx: RouterContext, _: Next) { + const { guid } = ctx.params; + let attachment = await Server().iMessageRepo.getAttachment(guid); + if (!attachment) { + throw new BadRequest({ message: `An attachment with the GUID, "${guid}" does not exist!` }) + } + + await Server().privateApi.attachment.downloadPurged(guid); + + // Wait a max of 10 minutes + attachment = await resultAwaiter({ + maxWaitMs: 1000 * 60 * 10, + initialWaitMs: 1000 * 5, + waitMultiplier: 1, + getData: (previousData: any) => { + return Server().iMessageRepo.getAttachment(guid); + }, + dataLoopCondition: (data: Attachment) => { + return !data || data.transferState !== 5; + } + }); + + if (!attachment || attachment.transferState !== 5) { + throw new ServerError({ + error: `Failed to download attachment! Transfer State: ${attachment?.transferState}` }); + } + + return await AttachmentRouter.download(ctx, _); + } } diff --git a/packages/server/src/server/services/httpService/api/v1/routers/chatRouter.ts b/packages/server/src/server/api/http/api/v1/routers/chatRouter.ts similarity index 90% rename from packages/server/src/server/services/httpService/api/v1/routers/chatRouter.ts rename to packages/server/src/server/api/http/api/v1/routers/chatRouter.ts index 89bd200f..155b6683 100644 --- a/packages/server/src/server/services/httpService/api/v1/routers/chatRouter.ts +++ b/packages/server/src/server/api/http/api/v1/routers/chatRouter.ts @@ -5,14 +5,14 @@ import { Server } from "@server"; import { FileSystem } from "@server/fileSystem"; import { DBMessageParams } from "@server/databases/imessage/types"; import { isEmpty, isNotEmpty, isTruthyBool } from "@server/helpers/utils"; -import { ChatInterface } from "@server/api/v1/interfaces/chatInterface"; -import { MessageSerializer } from "@server/api/v1/serializers/MessageSerializer"; +import { ChatInterface } from "@server/api/interfaces/chatInterface"; +import { MessageSerializer } from "@server/api/serializers/MessageSerializer"; import { arrayHasOne } from "@server/utils/CollectionUtils"; import { FileStream, Success } from "../responses/success"; import { IMessageError, NotFound } from "../responses/errors"; import { parseWithQuery } from "../utils"; -import { ChatSerializer } from "@server/api/v1/serializers/ChatSerializer"; +import { ChatSerializer } from "@server/api/serializers/ChatSerializer"; export class ChatRouter { static async count(ctx: RouterContext, _: Next) { @@ -123,7 +123,13 @@ export class ChatRouter { const withLastMessage = arrayHasOne(withQuery, ["lastmessage", "last-message"]); const guid = body?.guid; - const { sort, offset, limit } = body; + let sort = body?.sort; + const { offset, limit } = body; + + // Default to sorting by last message if with last message + if (withLastMessage && isEmpty(sort)) { + sort = "lastmessage" + } // Fetch the chats const [results, total] = await ChatInterface.get({ @@ -339,4 +345,24 @@ export class ChatRouter { await ChatInterface.deleteChatMessage(chats[0], message); return new Success(ctx, { message: 'Successfully deleted message!' }).send(); } + + static async shouldShareContact(ctx: RouterContext, _: Next) { + const { guid: chatGuid } = ctx?.params ?? {}; + + const [chats, __] = await Server().iMessageRepo.getChats({ chatGuid, withParticipants: false }); + if (isEmpty(chats)) throw new NotFound({ error: "Chat does not exist!" }); + + const canShare = await ChatInterface.canShareContactInfo(chats[0].guid); + return new Success(ctx, { message: 'Successfully got contact sharing status!', data: canShare }).send(); + } + + static async shareContact(ctx: RouterContext, _: Next) { + const { guid: chatGuid } = ctx?.params ?? {}; + + const [chats, __] = await Server().iMessageRepo.getChats({ chatGuid, withParticipants: false }); + if (isEmpty(chats)) throw new NotFound({ error: "Chat does not exist!" }); + + await ChatInterface.shareContactInfo(chats[0].guid); + return new Success(ctx, { message: 'Successfully shared contact info!' }).send(); + } } diff --git a/packages/server/src/server/services/httpService/api/v1/routers/contactRouter.ts b/packages/server/src/server/api/http/api/v1/routers/contactRouter.ts similarity index 97% rename from packages/server/src/server/services/httpService/api/v1/routers/contactRouter.ts rename to packages/server/src/server/api/http/api/v1/routers/contactRouter.ts index 36b95c5b..4cf93b67 100644 --- a/packages/server/src/server/services/httpService/api/v1/routers/contactRouter.ts +++ b/packages/server/src/server/api/http/api/v1/routers/contactRouter.ts @@ -2,7 +2,7 @@ import { RouterContext } from "koa-router"; import { Next } from "koa"; import { isEmpty, isNotEmpty } from "@server/helpers/utils"; -import { ContactInterface } from "@server/api/v1/interfaces/contactInterface"; +import { ContactInterface } from "@server/api/interfaces/contactInterface"; import { Success } from "../responses/success"; import { Contact } from "@server/databases/server/entity"; import { parseWithQuery } from "../utils"; @@ -26,7 +26,7 @@ export class ContactRouter { static async query(ctx: RouterContext, _: Next) { const { body } = ctx.request; const addresses = body?.addresses ?? []; - const extraProps = parseWithQuery(body?.extraProperties ?? []); + const extraProps = parseWithQuery(body?.extraProperties ?? [], false); let res = []; if (isEmpty(addresses) || !Array.isArray(addresses)) { diff --git a/packages/server/src/server/api/http/api/v1/routers/facetimeRouter.ts b/packages/server/src/server/api/http/api/v1/routers/facetimeRouter.ts new file mode 100644 index 00000000..6e20b9fc --- /dev/null +++ b/packages/server/src/server/api/http/api/v1/routers/facetimeRouter.ts @@ -0,0 +1,21 @@ +import { Next } from "koa"; +import { RouterContext } from "koa-router"; +import { NoData, Success } from "../responses/success"; +import { FaceTimeInterface } from "@server/api/interfaces/facetimeInterface"; + +export class FaceTimeRouter { + static async answer(ctx: RouterContext, _: Next) { + const link = await FaceTimeInterface.answer(ctx.params.call_uuid); + return new Success(ctx, { data: { link } }).send(); + } + + static async leave(ctx: RouterContext, _: Next) { + await FaceTimeInterface.leave(ctx.params.call_uuid); + return new NoData(ctx, {}).send(); + } + + static async newSession(ctx: RouterContext, _: Next) { + const link = await FaceTimeInterface.create(); + return new Success(ctx, { data: { link }}).send(); + } +} diff --git a/packages/server/src/server/services/httpService/api/v1/routers/fcmRouter.ts b/packages/server/src/server/api/http/api/v1/routers/fcmRouter.ts similarity index 95% rename from packages/server/src/server/services/httpService/api/v1/routers/fcmRouter.ts rename to packages/server/src/server/api/http/api/v1/routers/fcmRouter.ts index c2f8e0a0..02a3ee02 100644 --- a/packages/server/src/server/services/httpService/api/v1/routers/fcmRouter.ts +++ b/packages/server/src/server/api/http/api/v1/routers/fcmRouter.ts @@ -2,7 +2,7 @@ import { Next } from "koa"; import { RouterContext } from "koa-router"; import { FileSystem } from "@server/fileSystem"; -import { GeneralInterface } from "@server/api/v1/interfaces/generalInterface"; +import { GeneralInterface } from "@server/api/interfaces/generalInterface"; import { Success } from "../responses/success"; import { isEmpty, isNotEmpty } from "@server/helpers/utils"; import { NotFound } from "../responses/errors"; diff --git a/packages/server/src/server/services/httpService/api/v1/routers/generalRouter.ts b/packages/server/src/server/api/http/api/v1/routers/generalRouter.ts similarity index 100% rename from packages/server/src/server/services/httpService/api/v1/routers/generalRouter.ts rename to packages/server/src/server/api/http/api/v1/routers/generalRouter.ts diff --git a/packages/server/src/server/services/httpService/api/v1/routers/handleRouter.ts b/packages/server/src/server/api/http/api/v1/routers/handleRouter.ts similarity index 95% rename from packages/server/src/server/services/httpService/api/v1/routers/handleRouter.ts rename to packages/server/src/server/api/http/api/v1/routers/handleRouter.ts index 85363646..77a5db48 100644 --- a/packages/server/src/server/services/httpService/api/v1/routers/handleRouter.ts +++ b/packages/server/src/server/api/http/api/v1/routers/handleRouter.ts @@ -2,13 +2,13 @@ import { RouterContext } from "koa-router"; import { Next } from "koa"; import { Server } from "@server"; -import { HandleInterface } from "@server/api/v1/interfaces/handleInterface"; +import { HandleInterface } from "@server/api/interfaces/handleInterface"; import { getiMessageAddressFormat, isEmpty } from "@server/helpers/utils"; import { arrayHasOne } from "@server/utils/CollectionUtils"; import { Success } from "../responses/success"; import { NotFound } from "../responses/errors"; import { parseWithQuery } from "../utils"; -import { HandleSerializer } from "@server/api/v1/serializers/HandleSerializer"; +import { HandleSerializer } from "@server/api/serializers/HandleSerializer"; export class HandleRouter { static async count(ctx: RouterContext, _: Next) { diff --git a/packages/server/src/server/services/httpService/api/v1/routers/icloudRouter.ts b/packages/server/src/server/api/http/api/v1/routers/icloudRouter.ts similarity index 53% rename from packages/server/src/server/services/httpService/api/v1/routers/icloudRouter.ts rename to packages/server/src/server/api/http/api/v1/routers/icloudRouter.ts index a5d9e4ff..95f38701 100644 --- a/packages/server/src/server/services/httpService/api/v1/routers/icloudRouter.ts +++ b/packages/server/src/server/api/http/api/v1/routers/icloudRouter.ts @@ -3,6 +3,8 @@ import { RouterContext } from "koa-router"; import { Success } from "../responses/success"; import { ServerError } from "../responses/errors"; import { FindMyService } from "@server/services/findMyService"; +import { iCloudInterface } from "@server/api/interfaces/iCloudInterface"; +import { findMyInterface } from "@server/api/interfaces/findMyInterface"; export class iCloudRouter { static async refreshDevices(ctx: RouterContext, _: Next) { @@ -17,7 +19,8 @@ export class iCloudRouter { static async refreshFriends(ctx: RouterContext, _: Next) { try { - const data = await FindMyService.refreshFriends(); + await FindMyService.refreshFriends(); + const data = findMyInterface.getFriends(); return new Success(ctx, { message: "Successfully refreshed Find My friends locations!", data }).send(); } catch (ex: any) { throw new ServerError( @@ -37,11 +40,40 @@ export class iCloudRouter { static async friends(ctx: RouterContext, _: Next) { try { - const data = await FindMyService.getFriends(); + const data: any = await findMyInterface.getFriends(); return new Success(ctx, { message: "Successfully fetched Find My friends locations!", data }).send(); } catch (ex: any) { throw new ServerError( { message: "Failed to fetch Find My friends locations!", error: ex?.message ?? ex.toString() }); } } + + static async getAccountInfo(ctx: RouterContext, _: Next) { + try { + const data: any = await iCloudInterface.getAccountInfo(); + return new Success(ctx, { message: "Successfully fetched account info!", data }).send(); + } catch (ex: any) { + throw new ServerError({ message: "Failed to fetch account info!", error: ex?.message ?? ex.toString() }); + } + } + + static async getContactCard(ctx: RouterContext, _: Next) { + try { + const { address } = ctx.request.query; + const data: any = await iCloudInterface.getContactCard(address as string); + return new Success(ctx, { message: "Successfully fetched contact card!", data }).send(); + } catch (ex: any) { + throw new ServerError({ message: "Failed to fetch contact card!", error: ex?.message ?? ex.toString() }); + } + } + + static async changeAlias(ctx: RouterContext, _: Next) { + try { + const { alias } = ctx?.request?.body ?? {}; + const data: any = await iCloudInterface.modifyActiveAlias(alias); + return new Success(ctx, { message: "Successfully changed iMessage Alias!", data }).send(); + } catch (ex: any) { + throw new ServerError({ message: "Failed to change iMessage Alias", error: ex?.message ?? ex.toString() }); + } + } } diff --git a/packages/server/src/server/services/httpService/api/v1/routers/macosRouter.ts b/packages/server/src/server/api/http/api/v1/routers/macosRouter.ts similarity index 92% rename from packages/server/src/server/services/httpService/api/v1/routers/macosRouter.ts rename to packages/server/src/server/api/http/api/v1/routers/macosRouter.ts index a6bebe0c..760f5514 100644 --- a/packages/server/src/server/services/httpService/api/v1/routers/macosRouter.ts +++ b/packages/server/src/server/api/http/api/v1/routers/macosRouter.ts @@ -1,6 +1,6 @@ import { Next } from "koa"; import { RouterContext } from "koa-router"; -import { MacOsInterface } from "@server/api/v1/interfaces/macosInterface"; +import { MacOsInterface } from "@server/api/interfaces/macosInterface"; import { Success } from "../responses/success"; import { ServerError } from "../responses/errors"; diff --git a/packages/server/src/server/services/httpService/api/v1/routers/messageRouter.ts b/packages/server/src/server/api/http/api/v1/routers/messageRouter.ts similarity index 98% rename from packages/server/src/server/services/httpService/api/v1/routers/messageRouter.ts rename to packages/server/src/server/api/http/api/v1/routers/messageRouter.ts index e4327542..6af5f34e 100644 --- a/packages/server/src/server/services/httpService/api/v1/routers/messageRouter.ts +++ b/packages/server/src/server/api/http/api/v1/routers/messageRouter.ts @@ -7,9 +7,9 @@ import { Server } from "@server"; import { FileSystem } from "@server/fileSystem"; import { isEmpty, isNotEmpty } from "@server/helpers/utils"; import { Message } from "@server/databases/imessage/entity/Message"; -import { MessageInterface } from "@server/api/v1/interfaces/messageInterface"; +import { MessageInterface } from "@server/api/interfaces/messageInterface"; import { MessagePromiseRejection } from "@server/managers/outgoingMessageManager/messagePromise"; -import { MessageSerializer } from "@server/api/v1/serializers/MessageSerializer"; +import { MessageSerializer } from "@server/api/serializers/MessageSerializer"; import { arrayHasOne } from "@server/utils/CollectionUtils"; import { FileStream, Success } from "../responses/success"; import { BadRequest, IMessageError, NotFound } from "../responses/errors"; @@ -180,8 +180,10 @@ export class MessageRouter { } static async sendText(ctx: RouterContext, _: Next) { - let { tempGuid, message, attributedBody, method, chatGuid, effectId, subject, selectedMessageGuid, partIndex } = - ctx?.request?.body ?? {}; + let { + tempGuid, message, attributedBody, method, chatGuid, + effectId, subject, selectedMessageGuid, partIndex, ddScan + } = ctx?.request?.body ?? {}; // Add to send cache Server().httpService.sendCache.add(tempGuid); @@ -197,7 +199,8 @@ export class MessageRouter { effectId, selectedMessageGuid, tempGuid, - partIndex + partIndex, + ddScan }); // Remove from cache @@ -341,7 +344,7 @@ export class MessageRouter { } static async sendMultipartMessage(ctx: RouterContext, _: Next) { - let { parts, tempGuid, attributedBody, chatGuid, effectId, subject, selectedMessageGuid, partIndex } = + let { parts, tempGuid, attributedBody, chatGuid, effectId, subject, selectedMessageGuid, partIndex, ddScan } = ctx?.request?.body ?? {}; // Remove from cache @@ -358,7 +361,8 @@ export class MessageRouter { subject, effectId, selectedMessageGuid, - partIndex + partIndex, + ddScan }); // Remove from cache diff --git a/packages/server/src/server/services/httpService/api/v1/routers/scheduledMessageRouter.ts b/packages/server/src/server/api/http/api/v1/routers/scheduledMessageRouter.ts similarity index 96% rename from packages/server/src/server/services/httpService/api/v1/routers/scheduledMessageRouter.ts rename to packages/server/src/server/api/http/api/v1/routers/scheduledMessageRouter.ts index a90edc74..4529c59c 100644 --- a/packages/server/src/server/services/httpService/api/v1/routers/scheduledMessageRouter.ts +++ b/packages/server/src/server/api/http/api/v1/routers/scheduledMessageRouter.ts @@ -1,4 +1,4 @@ -import { ScheduledMessagesInterface } from "@server/api/v1/interfaces/scheduledMessagesInterface"; +import { ScheduledMessagesInterface } from "@server/api/interfaces/scheduledMessagesInterface"; import { Next } from "koa"; import { RouterContext } from "koa-router"; import { Success } from "../responses/success"; diff --git a/packages/server/src/server/services/httpService/api/v1/routers/serverRouter.ts b/packages/server/src/server/api/http/api/v1/routers/serverRouter.ts similarity index 94% rename from packages/server/src/server/services/httpService/api/v1/routers/serverRouter.ts rename to packages/server/src/server/api/http/api/v1/routers/serverRouter.ts index 8b258d4c..1f0d45ee 100644 --- a/packages/server/src/server/services/httpService/api/v1/routers/serverRouter.ts +++ b/packages/server/src/server/api/http/api/v1/routers/serverRouter.ts @@ -2,10 +2,10 @@ import { Next } from "koa"; import { RouterContext } from "koa-router"; import { FileSystem } from "@server/fileSystem"; import { Server } from "@server"; -import { ServerInterface } from "@server/api/v1/interfaces/serverInterface"; -import { GeneralInterface } from "@server/api/v1/interfaces/generalInterface"; +import { ServerInterface } from "@server/api/interfaces/serverInterface"; +import { GeneralInterface } from "@server/api/interfaces/generalInterface"; import { Success } from "../responses/success"; -import { AlertsInterface } from "@server/api/v1/interfaces/alertsInterface"; +import { AlertsInterface } from "@server/api/interfaces/alertsInterface"; import { isEmpty, isTruthyBool } from "@server/helpers/utils"; import { BadRequest } from "../responses/errors"; import { autoUpdater } from "electron-updater"; diff --git a/packages/server/src/server/services/httpService/api/v1/routers/settingsRouter.ts b/packages/server/src/server/api/http/api/v1/routers/settingsRouter.ts similarity index 95% rename from packages/server/src/server/services/httpService/api/v1/routers/settingsRouter.ts rename to packages/server/src/server/api/http/api/v1/routers/settingsRouter.ts index a90bd5eb..fdd8ab4e 100644 --- a/packages/server/src/server/services/httpService/api/v1/routers/settingsRouter.ts +++ b/packages/server/src/server/api/http/api/v1/routers/settingsRouter.ts @@ -2,7 +2,7 @@ import { RouterContext } from "koa-router"; import { Next } from "koa"; import { isNotEmpty } from "@server/helpers/utils"; -import { BackupsInterface } from "@server/api/v1/interfaces/backupsInterface"; +import { BackupsInterface } from "@server/api/interfaces/backupsInterface"; import { Success } from "../responses/success"; export class SettingsRouter { diff --git a/packages/server/src/server/services/httpService/api/v1/routers/themeRouter.ts b/packages/server/src/server/api/http/api/v1/routers/themeRouter.ts similarity index 95% rename from packages/server/src/server/services/httpService/api/v1/routers/themeRouter.ts rename to packages/server/src/server/api/http/api/v1/routers/themeRouter.ts index 18abf456..cd6ce152 100644 --- a/packages/server/src/server/services/httpService/api/v1/routers/themeRouter.ts +++ b/packages/server/src/server/api/http/api/v1/routers/themeRouter.ts @@ -2,7 +2,7 @@ import { RouterContext } from "koa-router"; import { Next } from "koa"; import { isNotEmpty } from "@server/helpers/utils"; -import { BackupsInterface } from "@server/api/v1/interfaces/backupsInterface"; +import { BackupsInterface } from "@server/api/interfaces/backupsInterface"; import { Success } from "../responses/success"; export class ThemeRouter { diff --git a/packages/server/src/server/services/httpService/api/v1/routers/uiRouter.ts b/packages/server/src/server/api/http/api/v1/routers/uiRouter.ts similarity index 100% rename from packages/server/src/server/services/httpService/api/v1/routers/uiRouter.ts rename to packages/server/src/server/api/http/api/v1/routers/uiRouter.ts diff --git a/packages/server/src/server/services/httpService/api/v1/socketRoutes.ts b/packages/server/src/server/api/http/api/v1/socketRoutes.ts similarity index 98% rename from packages/server/src/server/services/httpService/api/v1/socketRoutes.ts rename to packages/server/src/server/api/http/api/v1/socketRoutes.ts index b275c815..08f18892 100644 --- a/packages/server/src/server/services/httpService/api/v1/socketRoutes.ts +++ b/packages/server/src/server/api/http/api/v1/socketRoutes.ts @@ -17,15 +17,15 @@ import { isEmpty, isNotEmpty, onlyAlphaNumeric, safeTrim } from "@server/helpers // Helpers import { ChatResponse, HandleResponse, ServerMetadataResponse } from "@server/types"; -import { ErrorTypes, ResponseFormat, ResponseJson } from "@server/services/httpService/api/v1/responses/types"; +import { ErrorTypes, ResponseFormat, ResponseJson } from "@server/api/http/api/v1/responses/types"; // Entities import { Handle } from "@server/databases/imessage/entity/Handle"; import { DBMessageParams } from "@server/databases/imessage/types"; -import { ActionHandler } from "@server/api/v1/apple/actions"; +import { ActionHandler } from "@server/api/apple/actions"; import { QueueItem } from "@server/services/queueService"; -import { GeneralInterface } from "@server/api/v1/interfaces/generalInterface"; -import { MessageInterface } from "@server/api/v1/interfaces/messageInterface"; +import { GeneralInterface } from "@server/api/interfaces/generalInterface"; +import { MessageInterface } from "@server/api/interfaces/messageInterface"; import { convertAudio } from "@server/databases/imessage/helpers/utils"; import { @@ -34,12 +34,12 @@ import { createBadRequestResponse, createNoDataResponse } from "./responses"; -import { MessageSerializer } from "@server/api/v1/serializers/MessageSerializer"; +import { MessageSerializer } from "@server/api/serializers/MessageSerializer"; import { CHAT_READ_STATUS_CHANGED } from "@server/events"; -import { ChatSerializer } from "@server/api/v1/serializers/ChatSerializer"; -import { HandleSerializer } from "@server/api/v1/serializers/HandleSerializer"; -import { AttachmentSerializer } from "@server/api/v1/serializers/AttachmentSerializer"; -import { MacOsInterface } from "@server/api/v1/interfaces/macosInterface"; +import { ChatSerializer } from "@server/api/serializers/ChatSerializer"; +import { HandleSerializer } from "@server/api/serializers/HandleSerializer"; +import { AttachmentSerializer } from "@server/api/serializers/AttachmentSerializer"; +import { MacOsInterface } from "@server/api/interfaces/macosInterface"; const unknownError = "Unknown Error. Check server logs!"; diff --git a/packages/server/src/server/services/httpService/api/v1/utils.ts b/packages/server/src/server/api/http/api/v1/utils.ts similarity index 50% rename from packages/server/src/server/services/httpService/api/v1/utils.ts rename to packages/server/src/server/api/http/api/v1/utils.ts index 3a404e0a..947bad91 100644 --- a/packages/server/src/server/services/httpService/api/v1/utils.ts +++ b/packages/server/src/server/api/http/api/v1/utils.ts @@ -1,18 +1,24 @@ import { safeTrim } from "@server/helpers/utils"; -export const parseWithQuery = (query: string | string[]): string[] => { +export const parseWithQuery = (query: string | string[], lower = true): string[] => { if (query == null) return []; + + let output: string[] = []; if (typeof query === "string") { - return (query ?? '') - .toLowerCase().split(",") + output = (query ?? '') + .split(",") .map(e => safeTrim(e)) .filter(e => e && e.length > 0); } else if (Array.isArray(query)) { - return (query ?? []) + output = (query ?? []) .filter((e: any) => typeof e === "string") - .map((e: string) => safeTrim(e.toLowerCase())) + .map((e: string) => safeTrim(e)) .filter(e => e && e.length > 0); } - return []; + if (lower) { + output = output.map(e => e.toLowerCase()); + } + + return output; }; \ No newline at end of file diff --git a/packages/server/src/server/services/httpService/api/v1/validators/alertsValidator.ts b/packages/server/src/server/api/http/api/v1/validators/alertsValidator.ts similarity index 100% rename from packages/server/src/server/services/httpService/api/v1/validators/alertsValidator.ts rename to packages/server/src/server/api/http/api/v1/validators/alertsValidator.ts diff --git a/packages/server/src/server/services/httpService/api/v1/validators/attachmentValidator.ts b/packages/server/src/server/api/http/api/v1/validators/attachmentValidator.ts similarity index 100% rename from packages/server/src/server/services/httpService/api/v1/validators/attachmentValidator.ts rename to packages/server/src/server/api/http/api/v1/validators/attachmentValidator.ts diff --git a/packages/server/src/server/services/httpService/api/v1/validators/chatValidator.ts b/packages/server/src/server/api/http/api/v1/validators/chatValidator.ts similarity index 100% rename from packages/server/src/server/services/httpService/api/v1/validators/chatValidator.ts rename to packages/server/src/server/api/http/api/v1/validators/chatValidator.ts diff --git a/packages/server/src/server/services/httpService/api/v1/validators/fcmValidator.ts b/packages/server/src/server/api/http/api/v1/validators/fcmValidator.ts similarity index 100% rename from packages/server/src/server/services/httpService/api/v1/validators/fcmValidator.ts rename to packages/server/src/server/api/http/api/v1/validators/fcmValidator.ts diff --git a/packages/server/src/server/services/httpService/api/v1/validators/handleValidator.ts b/packages/server/src/server/api/http/api/v1/validators/handleValidator.ts similarity index 100% rename from packages/server/src/server/services/httpService/api/v1/validators/handleValidator.ts rename to packages/server/src/server/api/http/api/v1/validators/handleValidator.ts diff --git a/packages/server/src/server/services/httpService/api/v1/validators/index.ts b/packages/server/src/server/api/http/api/v1/validators/index.ts similarity index 100% rename from packages/server/src/server/services/httpService/api/v1/validators/index.ts rename to packages/server/src/server/api/http/api/v1/validators/index.ts diff --git a/packages/server/src/server/services/httpService/api/v1/validators/messageValidator.ts b/packages/server/src/server/api/http/api/v1/validators/messageValidator.ts similarity index 96% rename from packages/server/src/server/services/httpService/api/v1/validators/messageValidator.ts rename to packages/server/src/server/api/http/api/v1/validators/messageValidator.ts index 8f1bdc31..d386cf8a 100644 --- a/packages/server/src/server/services/httpService/api/v1/validators/messageValidator.ts +++ b/packages/server/src/server/api/http/api/v1/validators/messageValidator.ts @@ -6,7 +6,7 @@ import fs from "fs"; import { Server } from "@server"; import { isEmpty } from "@server/helpers/utils"; import { FileSystem } from "@server/fileSystem"; -import { MessageInterface } from "@server/api/v1/interfaces/messageInterface"; +import { MessageInterface } from "@server/api/interfaces/messageInterface"; import { ValidateInput } from "./index"; import { BadRequest } from "../responses/errors"; @@ -74,11 +74,12 @@ export class MessageValidator { effectId: "string", subject: "string", selectedMessageGuid: "string", - partIndex: "numeric|min:0" + partIndex: "numeric|min:0", + ddScan: "boolean" }; static async validateText(ctx: RouterContext, next: Next) { - const { tempGuid, method, effectId, subject, selectedMessageGuid, message } = ValidateInput( + const { tempGuid, method, effectId, subject, selectedMessageGuid, message, ddScan } = ValidateInput( ctx.request.body, MessageValidator.sendTextRules ); @@ -89,7 +90,7 @@ export class MessageValidator { // If we have an effectId, subject, reply, or attributedBody // let's imply we want to use the Private API - if (effectId || subject || selectedMessageGuid || ctx.request.body.attributedBody) { + if (effectId || subject || selectedMessageGuid || ddScan || ctx.request.body.attributedBody) { saniMethod = "private-api"; } @@ -212,6 +213,7 @@ export class MessageValidator { selectedMessageGuid: "string", partIndex: "numeric|min:0", parts: "required|array", + ddScan: "boolean" }; static async validateMultipart(ctx: RouterContext, next: Next) { diff --git a/packages/server/src/server/services/httpService/api/v1/validators/scheduledMessageValidator.ts b/packages/server/src/server/api/http/api/v1/validators/scheduledMessageValidator.ts similarity index 100% rename from packages/server/src/server/services/httpService/api/v1/validators/scheduledMessageValidator.ts rename to packages/server/src/server/api/http/api/v1/validators/scheduledMessageValidator.ts diff --git a/packages/server/src/server/services/httpService/api/v1/validators/settingsValidator.ts b/packages/server/src/server/api/http/api/v1/validators/settingsValidator.ts similarity index 100% rename from packages/server/src/server/services/httpService/api/v1/validators/settingsValidator.ts rename to packages/server/src/server/api/http/api/v1/validators/settingsValidator.ts diff --git a/packages/server/src/server/services/httpService/api/v1/validators/themeValidator.ts b/packages/server/src/server/api/http/api/v1/validators/themeValidator.ts similarity index 100% rename from packages/server/src/server/services/httpService/api/v1/validators/themeValidator.ts rename to packages/server/src/server/api/http/api/v1/validators/themeValidator.ts diff --git a/packages/server/src/server/services/httpService/constants.ts b/packages/server/src/server/api/http/constants.ts similarity index 100% rename from packages/server/src/server/services/httpService/constants.ts rename to packages/server/src/server/api/http/constants.ts diff --git a/packages/server/src/server/services/httpService/index.ts b/packages/server/src/server/api/http/index.ts similarity index 100% rename from packages/server/src/server/services/httpService/index.ts rename to packages/server/src/server/api/http/index.ts diff --git a/packages/server/src/server/services/httpService/types.ts b/packages/server/src/server/api/http/types.ts similarity index 88% rename from packages/server/src/server/services/httpService/types.ts rename to packages/server/src/server/api/http/types.ts index 91f0e8d7..ec8359a9 100644 --- a/packages/server/src/server/services/httpService/types.ts +++ b/packages/server/src/server/api/http/types.ts @@ -22,6 +22,8 @@ export type HttpRoute = { middleware?: KoaMiddleware[]; validators?: KoaMiddleware[]; controller: KoaMiddleware; + requestTimeoutMs?: number; + responseTimeoutMs?: number; }; export type HttpRouteGroup = { @@ -29,6 +31,8 @@ export type HttpRouteGroup = { prefix?: string | null; middleware?: KoaMiddleware[]; routes: HttpRoute[]; + requestTimeoutMs?: number; + responseTimeoutMs?: number; }; export type KoaNext = () => Promise; diff --git a/packages/server/src/server/api/v1/interfaces/alertsInterface.ts b/packages/server/src/server/api/interfaces/alertsInterface.ts similarity index 100% rename from packages/server/src/server/api/v1/interfaces/alertsInterface.ts rename to packages/server/src/server/api/interfaces/alertsInterface.ts diff --git a/packages/server/src/server/api/v1/interfaces/attachmentInterface.ts b/packages/server/src/server/api/interfaces/attachmentInterface.ts similarity index 100% rename from packages/server/src/server/api/v1/interfaces/attachmentInterface.ts rename to packages/server/src/server/api/interfaces/attachmentInterface.ts diff --git a/packages/server/src/server/api/v1/interfaces/backupsInterface.ts b/packages/server/src/server/api/interfaces/backupsInterface.ts similarity index 100% rename from packages/server/src/server/api/v1/interfaces/backupsInterface.ts rename to packages/server/src/server/api/interfaces/backupsInterface.ts diff --git a/packages/server/src/server/api/v1/interfaces/chatInterface.ts b/packages/server/src/server/api/interfaces/chatInterface.ts similarity index 92% rename from packages/server/src/server/api/v1/interfaces/chatInterface.ts rename to packages/server/src/server/api/interfaces/chatInterface.ts index 90512409..0ae4e1f2 100644 --- a/packages/server/src/server/api/v1/interfaces/chatInterface.ts +++ b/packages/server/src/server/api/interfaces/chatInterface.ts @@ -4,12 +4,13 @@ import { checkPrivateApiStatus, getiMessageAddressFormat, isEmpty, - isMinBigSur, - isMinVentura, isNotEmpty, - resultAwaiter, - slugifyAddress + resultAwaiter } from "@server/helpers/utils"; +import { + isMinBigSur, + isMinVentura +} from "@server/env"; import { Server } from "@server"; import { FileSystem } from "@server/fileSystem"; import { ChatResponse } from "@server/types"; @@ -84,16 +85,14 @@ export class ChatInterface { } // If we have a sort parameter, handle the cases - if (sort) { - if (sort === "lastmessage" && withLastMessage) { - results.sort((a: ChatResponse, b: ChatResponse) => { - const d1 = a.lastMessage?.dateCreated ?? 0; - const d2 = b.lastMessage?.dateCreated ?? 0; - if (d1 > d2) return -1; - if (d1 < d2) return 1; - return 0; - }); - } + if (sort && sort === "lastmessage" && withLastMessage) { + results.sort((a: ChatResponse, b: ChatResponse) => { + const d1 = a.lastMessage?.dateCreated ?? a.lastMessage?.dateDelivered ?? 0; + const d2 = b.lastMessage?.dateCreated ?? b.lastMessage?.dateDelivered ?? 0; + if (d1 > d2) return -1; + if (d1 < d2) return 1; + return 0; + }); } return [results, totalChats]; @@ -349,7 +348,8 @@ export class ChatInterface { const retChat = await resultAwaiter({ maxWaitMs, getData: async (previousData: any | null) => { - const chats = await Server().iMessageRepo.getChats({ chatGuid: chat.guid, withParticipants: true }); + const [chats, _] = await Server().iMessageRepo.getChats( + { chatGuid: chat.guid, withParticipants: true }); return chats[0] ?? previousData; }, // Keep looping if the participant count is the same as before @@ -494,4 +494,18 @@ export class ChatInterface { static async deleteChatMessage(chat: Chat, message: Message): Promise { await Server().privateApi.chat.deleteMessage(chat.guid, message.guid); } + + static async canShareContactInfo(chatGuid: string): Promise { + checkPrivateApiStatus(); + if (!isMinBigSur) throw new Error("Contact sharing is only supported on macOS Big Sur or newer!"); + const result = await Server().privateApi.chat.shouldOfferContactSharing(chatGuid); + if (result?.data?.share == null) throw new Error("Failed to check if contact sharing is available!"); + return result.data.share; + } + + static async shareContactInfo(chatGuid: string): Promise { + checkPrivateApiStatus(); + if (!isMinBigSur) throw new Error("Contact sharing is only supported on macOS Big Sur or newer!"); + await Server().privateApi.chat.shareContactCard(chatGuid); + } } diff --git a/packages/server/src/server/api/v1/interfaces/contactInterface.ts b/packages/server/src/server/api/interfaces/contactInterface.ts similarity index 89% rename from packages/server/src/server/api/v1/interfaces/contactInterface.ts rename to packages/server/src/server/api/interfaces/contactInterface.ts index b6b840ce..8c22be53 100644 --- a/packages/server/src/server/api/v1/interfaces/contactInterface.ts +++ b/packages/server/src/server/api/interfaces/contactInterface.ts @@ -4,12 +4,8 @@ import vcf from "vcf"; import { Contact, ContactAddress } from "@server/databases/server/entity"; import * as base64 from "byte-base64"; import { deduplicateObjectArray, isEmpty, isNotEmpty } from "@server/helpers/utils"; -import { ApiContactsCache } from "../caches/apiContactsCache"; import type { FindOptionsWhere } from "typeorm"; - -const contacts = require("node-mac-contacts"); - -const contactsCache = new ApiContactsCache(); +import { ContactsLib } from "../lib/ContactsLib"; type GenericContactParams = { contactId?: number; @@ -17,18 +13,6 @@ type GenericContactParams = { }; export class ContactInterface { - static apiExtraProperties: string[] = [ - "jobTitle", - "departmentName", - "organizationName", - "middleName", - "note", - "contactImage", - "contactThumbnailImage", - "instantMessageAddresses", - "socialProfiles" - ]; - private static extractEmails = (contact: any) => { // Normalize all the email records const emails = [...(contact?.emails ?? []), ...(contact?.emailAddresses ?? []), ...(contact?.addresses ?? [])] @@ -77,40 +61,35 @@ export class ContactInterface { } = {} ): any { return records.map((contact: NodeJS.Dict) => { - // We have to make a copy so that when we "delete", we aren't deleting from the master copy. - const e = { ...contact }; - - // Only include extra properties that are asked for. - for (const prop of ContactInterface.apiExtraProperties) { - if (!extraProps.includes(prop) && Object.keys(e).includes(prop)) { - delete e[prop]; - } + // Load the avatar based on the selected extra fields + let avatar = null; + if (extraProps.includes("avatar")) { + avatar = contact?.avatar ?? contact?.contactImage ?? contact.contactImageThumbnail; + } else if (extraProps.includes("contactImage")) { + avatar = contact?.contactImage ?? contact.contactImageThumbnail; + } else if (extraProps.includes("contactImageThumbnail")) { + avatar = contact?.contactImageThumbnail; } - const useAvatar = - extraProps.includes("avatar") || - extraProps.includes("contactImage") || - extraProps.includes("contactImageThumbnail"); - const avatar = useAvatar ? e?.avatar ?? e?.contactImage ?? e.contactImageThumbnail : null; - - let displayName = e?.displayName; + let displayName = contact?.displayName; if (isEmpty(displayName)) { - if (isNotEmpty(e?.firstName) && isEmpty(e?.lastName)) displayName = e.firstName; - if (isNotEmpty(e?.firstName) && isNotEmpty(e?.lastName)) displayName = `${e.firstName} ${e.lastName}`; - if (isEmpty(displayName) && isNotEmpty(e?.nickname)) displayName = e.nickname; + if (isNotEmpty(contact?.firstName) && isEmpty(contact?.lastName)) displayName = contact.firstName; + if (isNotEmpty(contact?.firstName) && isNotEmpty(contact?.lastName)) + displayName = `${contact.firstName} ${contact.lastName}`; + if (isEmpty(displayName) && isNotEmpty(contact?.nickname)) displayName = contact.nickname; } return { - phoneNumbers: ContactInterface.extractPhoneNumbers(e), - emails: ContactInterface.extractEmails(e), - firstName: e?.firstName, - lastName: e?.lastName, + phoneNumbers: ContactInterface.extractPhoneNumbers(contact), + emails: ContactInterface.extractEmails(contact), + firstName: contact?.firstName, + lastName: contact?.lastName, displayName, - nickname: e?.nickname, - birthday: e?.birthday, + nickname: contact?.nickname, + birthday: contact?.birthday, avatar: isNotEmpty(avatar) ? base64.bytesToBase64(avatar) : "", sourceType, - id: e?.identifier ?? e?.id + id: contact?.identifier ?? contact?.id }; }); } @@ -123,7 +102,7 @@ export class ContactInterface { * @returns A contact entry dictionary */ static findContact(address: string, { preloadedContacts }: { preloadedContacts?: any[] | null } = {}): any | null { - const contactList = preloadedContacts ?? contacts.getAllContacts(); + const contactList = preloadedContacts ?? ContactsLib.getAllContacts(); const alphaNumericRegex = /[^a-zA-Z0-9_]/gi; const addr = address.replace(alphaNumericRegex, ""); @@ -172,7 +151,13 @@ export class ContactInterface { extraProps = extraProps.filter(e => e !== "avatar"); } - return ContactInterface.mapContacts(contactsCache.getApiContacts(), "api", { extraProps }); + // Also load the thumbnail if the image is requested. + // The regular thumbnail will take precedence over the contactImageThumbnail + if (extraProps.includes('contactImage') && !extraProps.includes('contactThumbnailImage')) { + extraProps.push('contactThumbnailImage'); + } + + return ContactInterface.mapContacts(ContactsLib.getAllContacts(extraProps), "api", { extraProps }); } /** @@ -191,11 +176,11 @@ export class ContactInterface { * @returns A list of contact entries from both the API and local DB */ static async getAllContacts(extraProperties: string[] = []): Promise { - extraProperties = extraProperties.map(e => e.toLowerCase()); + const saniProps = extraProperties.map(e => e.toLowerCase()); const withAvatars = - extraProperties.includes("contactimage") || - extraProperties.includes("contactthumbnailimage") || - extraProperties.includes("avatar"); + saniProps.includes("contactimage") || + saniProps.includes("contactthumbnailimage") || + saniProps.includes("avatar"); const apiContacts = ContactInterface.getApiContacts(extraProperties); const dbContacts = await ContactInterface.getDbContacts(withAvatars); return [...dbContacts, ...apiContacts]; @@ -543,8 +528,4 @@ export class ContactInterface { const repo = Server().repo.contacts(); await repo.clear(); } - - static refreshApiContacts() { - contactsCache.loadApiContacts(true); - } } diff --git a/packages/server/src/server/api/interfaces/facetimeInterface.ts b/packages/server/src/server/api/interfaces/facetimeInterface.ts new file mode 100644 index 00000000..73b9b6a6 --- /dev/null +++ b/packages/server/src/server/api/interfaces/facetimeInterface.ts @@ -0,0 +1,34 @@ +import { Server } from "@server"; +import { FaceTimeSession } from "../lib/facetime/FaceTimeSession"; +import { FaceTimeSessionManager } from "../lib/facetime/FacetimeSessionManager"; + +/** + * An interface to interact with Facetime + */ +export class FaceTimeInterface { + static async create(): Promise { + const session = await FaceTimeSession.fromGeneratedLink(null); + session.admitAndLeave(); + return session.url; + } + + static async answer(callUuid: string): Promise { + return await FaceTimeInterface.answerAndWaitForLink(callUuid); + } + + static async leave(callUuid: string): Promise { + await Server().privateApi.facetime.leaveCall(callUuid); + } + + private static async answerAndWaitForLink(callUuid: string): Promise { + return await new Promise((resolve, reject) => { + try { + FaceTimeSession.answerIncomingCall(callUuid, (link: string) => { + resolve(link); + }); + } catch (ex) { + reject(ex); + } + }); + } +} diff --git a/packages/server/src/server/api/interfaces/findMyInterface.ts b/packages/server/src/server/api/interfaces/findMyInterface.ts new file mode 100644 index 00000000..1efc82c2 --- /dev/null +++ b/packages/server/src/server/api/interfaces/findMyInterface.ts @@ -0,0 +1,20 @@ +import { Server } from "@server"; +import { isMinBigSur } from "@server/env"; +import { checkPrivateApiStatus } from "@server/helpers/utils"; +import { FindMyService } from "@server/services"; + +export class findMyInterface { + static async getFriends() { + const papiEnabled = Server().repo.getConfig("enable_private_api") as boolean; + let data = null; + if (papiEnabled && isMinBigSur) { + checkPrivateApiStatus(); + const result = await Server().privateApi.findmy.getFriendsLocations(); + data = result?.data; + } else { + data = await FindMyService.getFriends(); + } + + return data; + } +} diff --git a/packages/server/src/server/api/v1/interfaces/generalInterface.ts b/packages/server/src/server/api/interfaces/generalInterface.ts similarity index 97% rename from packages/server/src/server/api/v1/interfaces/generalInterface.ts rename to packages/server/src/server/api/interfaces/generalInterface.ts index 109d2392..0e963e34 100644 --- a/packages/server/src/server/api/v1/interfaces/generalInterface.ts +++ b/packages/server/src/server/api/interfaces/generalInterface.ts @@ -4,7 +4,7 @@ import macosVersion from "macos-version"; import { Server } from "@server"; import { ServerMetadataResponse } from "@server/types"; import { Device } from "@server/databases/server/entity"; -import { UpdateResult } from "@server/services/httpService/types"; +import { UpdateResult } from "@server/api/http/types"; import { FileSystem } from "@server/fileSystem"; const osVersion = macosVersion(); diff --git a/packages/server/src/server/api/v1/interfaces/handleInterface.ts b/packages/server/src/server/api/interfaces/handleInterface.ts similarity index 97% rename from packages/server/src/server/api/v1/interfaces/handleInterface.ts rename to packages/server/src/server/api/interfaces/handleInterface.ts index 01df2225..3191df9c 100644 --- a/packages/server/src/server/api/v1/interfaces/handleInterface.ts +++ b/packages/server/src/server/api/interfaces/handleInterface.ts @@ -3,7 +3,8 @@ import { ChatResponse, HandleResponse } from "@server/types"; import { HandleSerializer } from "../serializers/HandleSerializer"; import { ChatInterface } from "./chatInterface"; import { Handle } from "@server/databases/imessage/entity/Handle"; -import { checkPrivateApiStatus, getiMessageAddressFormat, isEmpty, isMinMonterey } from "@server/helpers/utils"; +import { checkPrivateApiStatus, getiMessageAddressFormat, isEmpty } from "@server/helpers/utils"; +import { isMinMonterey } from "@server/env"; export class HandleInterface { static async get({ diff --git a/packages/server/src/server/api/interfaces/iCloudInterface.ts b/packages/server/src/server/api/interfaces/iCloudInterface.ts new file mode 100644 index 00000000..3db5e191 --- /dev/null +++ b/packages/server/src/server/api/interfaces/iCloudInterface.ts @@ -0,0 +1,43 @@ +import { Server } from "@server"; +import { isMinBigSur, isMinHighSierra } from "@server/env"; +import { checkPrivateApiStatus, isNotEmpty } from "@server/helpers/utils"; +import { bytesToBase64 } from "byte-base64"; + +export class iCloudInterface { + static async getAccountInfo() { + checkPrivateApiStatus(); + if (!isMinHighSierra) { + throw new Error("This API is only available on macOS Big Sur and newer!"); + } + + const data = await Server().privateApi.cloud.getAccountInfo(); + return data.data; + } + + static async getContactCard(address: string = null, loadAvatar = true) { + checkPrivateApiStatus(); + if (!isMinBigSur) { + throw new Error("This API is only available on macOS Monterey and newer!"); + } + + const data = await Server().privateApi.cloud.getContactCard(address); + const avatarPath = data?.data?.avatar_path; + if (isNotEmpty(avatarPath) && loadAvatar) { + data.data.avatar = bytesToBase64(fs.readFileSync(avatarPath)); + delete data.data.avatar_path; + } + + return data.data; + } + + static async modifyActiveAlias(alias: string) { + checkPrivateApiStatus(); + const accountInfo = await this.getAccountInfo(); + const aliases = (accountInfo.vetted_aliases ?? []).map((e: any) => e.Alias); + if (!aliases.includes(alias)) { + throw new Error(`Alias, "${alias}" is not assigned/enabled for your iCloud account!`); + } + + await Server().privateApi.cloud.modifyActiveAlias(alias); + } +} diff --git a/packages/server/src/server/api/v1/interfaces/macosInterface.ts b/packages/server/src/server/api/interfaces/macosInterface.ts similarity index 95% rename from packages/server/src/server/api/v1/interfaces/macosInterface.ts rename to packages/server/src/server/api/interfaces/macosInterface.ts index dffa7072..3e3b72fb 100644 --- a/packages/server/src/server/api/v1/interfaces/macosInterface.ts +++ b/packages/server/src/server/api/interfaces/macosInterface.ts @@ -1,8 +1,8 @@ import { FileSystem } from "@server/fileSystem"; -import { lockMacOs, restartMessages } from "@server/api/v1/apple/scripts"; +import { lockMacOs, restartMessages } from "@server/api/apple/scripts"; import { Server } from "@server"; import { safeTrim } from "@server/helpers/utils"; -import { ProcessDylibMode } from "@server/services/privateApi/modes/ProcessDylibMode"; +import { ProcessDylibMode } from "@server/api/privateApi/modes/ProcessDylibMode"; export class MacOsInterface { static async lock() { diff --git a/packages/server/src/server/api/v1/interfaces/messageInterface.ts b/packages/server/src/server/api/interfaces/messageInterface.ts similarity index 97% rename from packages/server/src/server/api/v1/interfaces/messageInterface.ts rename to packages/server/src/server/api/interfaces/messageInterface.ts index ad965715..ab0f894a 100644 --- a/packages/server/src/server/api/v1/interfaces/messageInterface.ts +++ b/packages/server/src/server/api/interfaces/messageInterface.ts @@ -6,14 +6,16 @@ import { Message } from "@server/databases/imessage/entity/Message"; import { checkPrivateApiStatus, isEmpty, - isMinMonterey, - isMinVentura, isNotEmpty, resultAwaiter } from "@server/helpers/utils"; -import { negativeReactionTextMap, reactionTextMap } from "@server/api/v1/apple/mappings"; -import { invisibleMediaChar } from "@server/services/httpService/constants"; -import { ActionHandler } from "@server/api/v1/apple/actions"; +import { + isMinMonterey, + isMinVentura +} from "@server/env"; +import { negativeReactionTextMap, reactionTextMap } from "@server/api/apple/mappings"; +import { invisibleMediaChar } from "@server/api/http/constants"; +import { ActionHandler } from "@server/api/apple/actions"; import type { SendMessageParams, SendAttachmentParams, @@ -23,7 +25,7 @@ import type { EditMessageParams, SendAttachmentPrivateApiParams, SendMultipartTextParams -} from "@server/api/v1/types"; +} from "@server/api/types"; import { Chat } from "@server/databases/imessage/entity/Chat"; import path from "path"; @@ -62,7 +64,8 @@ export class MessageInterface { effectId = null, selectedMessageGuid = null, tempGuid = null, - partIndex = 0 + partIndex = 0, + ddScan = false }: SendMessageParams): Promise { if (!chatGuid) throw new Error("No chat GUID provided"); @@ -105,7 +108,8 @@ export class MessageInterface { subject, effectId, selectedMessageGuid, - partIndex + partIndex, + ddScan }); } else { throw new Error(`Invalid send method: ${method}`); @@ -226,7 +230,8 @@ export class MessageInterface { subject = null, effectId = null, selectedMessageGuid = null, - partIndex = 0 + partIndex = 0, + ddScan = false }: SendMessagePrivateApiParams) { checkPrivateApiStatus(); const result = await Server().privateApi.message.send( @@ -236,7 +241,8 @@ export class MessageInterface { subject ?? null, effectId ?? null, selectedMessageGuid ?? null, - partIndex ?? 0 + partIndex ?? 0, + ddScan ?? false ); if (!result?.identifier) { @@ -529,7 +535,8 @@ export class MessageInterface { effectId = null, selectedMessageGuid = null, partIndex = 0, - parts = [] + parts = [], + ddScan = false }: SendMultipartTextParams): Promise { checkPrivateApiStatus(); if (!chatGuid) throw new Error("No chat GUID provided"); @@ -556,7 +563,8 @@ export class MessageInterface { subject ?? null, effectId ?? null, selectedMessageGuid ?? null, - partIndex ?? 0 + partIndex ?? 0, + ddScan ?? false ); if (!result?.identifier) { diff --git a/packages/server/src/server/api/v1/interfaces/scheduledMessagesInterface.ts b/packages/server/src/server/api/interfaces/scheduledMessagesInterface.ts similarity index 100% rename from packages/server/src/server/api/v1/interfaces/scheduledMessagesInterface.ts rename to packages/server/src/server/api/interfaces/scheduledMessagesInterface.ts diff --git a/packages/server/src/server/api/v1/interfaces/serverInterface.ts b/packages/server/src/server/api/interfaces/serverInterface.ts similarity index 100% rename from packages/server/src/server/api/v1/interfaces/serverInterface.ts rename to packages/server/src/server/api/interfaces/serverInterface.ts diff --git a/packages/server/src/server/api/lib/ContactsLib.ts b/packages/server/src/server/api/lib/ContactsLib.ts new file mode 100644 index 00000000..8225a91a --- /dev/null +++ b/packages/server/src/server/api/lib/ContactsLib.ts @@ -0,0 +1,69 @@ +import { Server } from "@server"; +import { isMinHighSierra } from "@server/env"; + +// Only import node-mac-contacts if we are on macOS 10.13 or higher +// This is because node-mac-contacts was compiled for macOS 10.13 or higher +// This library is here to prevent a crash on lower macOS versions +let contacts: any = null; +try { + if (isMinHighSierra) contacts = require("node-mac-contacts"); +} catch { + contacts = null; +} + +// Var to track if this is the first time we are loading contacts. +// If it's the first time, we need to load all available info, so it's cached +let isFirstLoad = true; + +export class ContactsLib { + static allExtraProps = [ + 'jobTitle', + 'departmentName', + 'organizationName', + 'middleName', + 'note', + 'contactImage', + 'contactThumbnailImage', + 'instantMessageAddresses', + 'socialProfiles' + ]; + + static async requestAccess() { + if (!contacts) return "Unknown"; + return await contacts.requestAccess(); + } + + static getAuthStatus() { + if (!contacts) return "Unknown"; + return contacts.getAuthStatus(); + } + + static getContactPermissionStatus() { + if (!contacts) return "Unknown"; + return contacts.getContactPermissionStatus(); + } + + static getAllContacts(extraProps: string[] = []) { + if (!contacts) return []; + + // If it's the first load, we need to load all available info. + // And also listen for changes so we can reload all the info again. + if (isFirstLoad) { + isFirstLoad = false; + contacts.getAllContacts(ContactsLib.allExtraProps); + ContactsLib.listenForChanges(); + } + + return contacts.getAllContacts(extraProps); + } + + static listenForChanges() { + if (!contacts) return; + contacts.listener.setup(); + contacts.listener.once('contact-changed', (_: string) => { + Server().log("Detected contact change, queueing full reload...", "debug"); + isFirstLoad = true; + contacts.listener.remove(); + }); + } +} \ No newline at end of file diff --git a/packages/server/src/server/api/lib/facetime/FaceTimeSession.ts b/packages/server/src/server/api/lib/facetime/FaceTimeSession.ts new file mode 100644 index 00000000..ad9f1066 --- /dev/null +++ b/packages/server/src/server/api/lib/facetime/FaceTimeSession.ts @@ -0,0 +1,298 @@ +import { Server } from "@server"; +import { FaceTimeSessionManager } from "./FacetimeSessionManager"; +import { NotificationCenterDB } from "@server/databases/notificationCenter/NotiicationCenterRepository"; +import { convertDateToCocoaTime } from "@server/databases/imessage/helpers/dateUtil"; +import { waitMs } from "@server/helpers/utils"; +import { EventEmitter } from "events"; +import { uuidv4 } from "@firebase/util"; + +export enum FaceTimeSessionStatus { + UNKNOWN = 0, + ANSWERED = 1, + OUTGOING = 3, + INCOMING = 4, + DISCONNECTED = 6 +} + +export const callStatusMap: Record = { + [FaceTimeSessionStatus.UNKNOWN]: "unknown", + [FaceTimeSessionStatus.ANSWERED]: "answered", + [FaceTimeSessionStatus.OUTGOING]: "outgoing", + [FaceTimeSessionStatus.INCOMING]: "incoming", + [FaceTimeSessionStatus.DISCONNECTED]: "disconnected" +}; + +export class FaceTimeSession extends EventEmitter { + uuid: string; + + conversationUuid: string; + + callUuid: string; + + url: string; + + admittedParticipants: string[] = []; + + selfAdmissionAwaiter: NodeJS.Timeout = null; + + createdAt: Date; + + status: FaceTimeSessionStatus = FaceTimeSessionStatus.UNKNOWN; + + constructor({ + callUuid = null, + conversationUuid = null, + addToManager = true + }: { + callUuid?: string; + conversationUuid?: string; + addToManager?: boolean; + } = {}) { + super(); + + this.uuid = uuidv4(); + + this.createdAt = new Date(); + this.callUuid = callUuid; + this.conversationUuid = conversationUuid; + + if (addToManager) { + FaceTimeSessionManager().addSession(this); + } + + // After 3 hours, invalidate the FaceTime session + setTimeout(() => { + this.invalidate(); + }, 1000 * 60 * 60 * 3); + } + + invalidate() { + this.status = FaceTimeSessionStatus.DISCONNECTED; + this.conversationUuid = null; + this.callUuid = null; + this.url = null; + this.admittedParticipants = []; + + if (this.selfAdmissionAwaiter) { + clearInterval(this.selfAdmissionAwaiter); + this.selfAdmissionAwaiter = null; + } + + this.emit("disconnected"); + } + + update(session: FaceTimeSession) { + if (session.status === FaceTimeSessionStatus.DISCONNECTED) { + this.invalidate(); + } else if (this.status === FaceTimeSessionStatus.INCOMING) { + if ([FaceTimeSessionStatus.INCOMING, FaceTimeSessionStatus.ANSWERED].includes(session.status)) { + this.status = FaceTimeSessionStatus.ANSWERED; + this.emit("answered", true); + } + } + } + + private async admitSelfHandler(since: Date): Promise { + // Check the notification center database for a waiting room notification + const [notifications, _] = await NotificationCenterDB().getRecords({ + sort: "ASC", // ascending so we can process the oldest first + where: [ + { + statement: `app.identifier = :identifier`, + args: { + identifier: "com.apple.facetime" + } + }, + { + statement: `record.delivered_date >= :deliveredDate`, + args: { + deliveredDate: convertDateToCocoaTime(since) + } + } + ] + }); + + for (const notification of notifications) { + // If the notification is for this conversation, admit the participant + for (const d of notification.data) { + if (!d.req?.body.includes("join")) continue; + + // Extract the user data + const userData = d.req?.usda["$objects"] ?? []; + + // If our data is incomplete, skip + if (userData.length < 10) continue; + + const userId = userData[6]; + const conversationId = userData[9]; + + // If we'ave already admitted the user, skip + if (this.admittedParticipants.includes(userId)) continue; + + Server().log(`Admitting ${userId}, into Call: ${conversationId}`, "debug"); + await Server().privateApi.facetime.admitParticipant(conversationId, userId); + this.admittedParticipants.push(userId); + Server().log(`Admitted ${userId}!`, "debug"); + + // Exit if we have admitted a user + clearInterval(this.selfAdmissionAwaiter); + return true; + } + } + + return false; + } + + async admitSelf(): Promise { + return await new Promise((resolve, reject) => { + // Set with ana offset of 5 seconds + const startDate = new Date(new Date().getTime() - 5000); + this.selfAdmissionAwaiter = setInterval(async () => { + const admitted = await this.admitSelfHandler(startDate); + if (admitted) { + resolve(); + } + + // If the current time is more than 2 minutes, throw an error and leave the call. + const now = new Date().getTime(); + if (now - startDate.getTime() > 1000 * 60 * 2) { + await this.leaveCall(); + this.invalidate(); + reject("Failed to admit self into FaceTime call!. No join requests detected in 2 minutes."); + } + }, 1000); + }); + } + + async generateLink(): Promise { + if (this.callUuid) { + Server().log(`Generating FaceTime Link for Call: ${this.callUuid}`); + } else { + Server().log(`Generating FaceTime Link For New Call`); + } + + // Invoke the private API + const result = await Server().privateApi.facetime.generateLink(this.callUuid); + if (!result?.data?.url) { + throw new Error("Failed to generate FaceTime link!"); + } + + // Set the url + this.url = result.data.url; + + // Emit URL to listeners + this.emit("link", this.url); + Server().log(`New FaceTime Link Generated: ${this.url}`); + return this.url; + } + + async admitParticipant(handleUuid: string): Promise { + // Invoke the private API + const result = await Server().privateApi.facetime.admitParticipant(this.conversationUuid, handleUuid); + if (!result?.data?.admittedParticipants) { + throw new Error("Failed to admit participant!"); + } + + // Set the admitted participants + this.admittedParticipants = result.data.admittedParticipants; + return this.admittedParticipants; + } + + async answerWithServer(): Promise { + Server().log(`Answering Call: ${this.callUuid}`); + + // Initialize the listener + const waiter = this.waitForAnswer(); + + // Answer the call + await Server().privateApi.facetime.answerCall(this.callUuid); + + // Wait for the listener to receive the answered event + await waiter; + } + + private async waitForAnswer(): Promise { + await new Promise((resolve, reject) => { + this.on("answered", () => { + resolve(); + }); + + // If the call is not answered in 30 seconds, reject + setTimeout(() => { + reject(new Error("Timed out waiting for FaceTime call to connect!")); + }, 1000 * 30); + }); + + // Wait 4 seconds to prevent crashing on Sonoma (and potentially other environments). + // 1 second is too short. 3 seconds will work 75% of the time. 4 seconds for good measure. + // Unfortunately, there isn't a good wait to detect when the call is ready to be used. + await waitMs(4000); + } + + async leaveCall(): Promise { + Server().log(`Leaving Call: ${this.callUuid}`); + await Server().privateApi.facetime.leaveCall(this.callUuid); + await waitMs(2000); + } + + async admitAndLeave(): Promise { + // Wait for the user, and admit them into the call + await this.admitSelf(); + + // Wait 15 seconds for the person to join + await waitMs(15000); + + // Once the user has been admitted, we can leave the call. + await this.leaveCall(); + } + + static async answerIncomingCall( + callUuid: string, + onLinkGenerated?: (link: string) => void + ): Promise { + let session = FaceTimeSessionManager().findSession(callUuid); + session ??= new FaceTimeSession({ callUuid }); + + if (session.status === FaceTimeSessionStatus.DISCONNECTED) { + throw new Error("Unable to answer call! The call has already ended."); + } + + session.status = FaceTimeSessionStatus.INCOMING; + + session.on("link", (link: string) => { + if (onLinkGenerated) { + onLinkGenerated(link); + } + }); + + // Answer the call and generate a link for it. + await session.answerWithServer(); + await session.generateLink(); + await session.admitAndLeave(); + return session; + } + + static async fromGeneratedLink(callUuid: string = null): Promise { + const session = new FaceTimeSession({ callUuid }); + + if (callUuid) { + if (session.status === FaceTimeSessionStatus.DISCONNECTED) { + throw new Error("Unable to generate link for call! The call has already ended."); + } + + session.status = FaceTimeSessionStatus.INCOMING; + await session.answerWithServer(); + } else { + session.status = FaceTimeSessionStatus.OUTGOING; + } + + await session.generateLink(); + return session; + } + + static fromEvent(event: any, addToManager = false): FaceTimeSession { + const session = new FaceTimeSession({ callUuid: event.call_uuid ?? null, addToManager }); + session.status = event.call_status; + return session; + } +} diff --git a/packages/server/src/server/api/lib/facetime/FacetimeSessionManager.ts b/packages/server/src/server/api/lib/facetime/FacetimeSessionManager.ts new file mode 100644 index 00000000..75e7f424 --- /dev/null +++ b/packages/server/src/server/api/lib/facetime/FacetimeSessionManager.ts @@ -0,0 +1,61 @@ +import { FaceTimeSession, FaceTimeSessionStatus } from "./FaceTimeSession"; + + +let instance: FaceTimeSessionManagerSingleton | null = null; +export const FaceTimeSessionManager = () => { + if (!instance) { + instance = new FaceTimeSessionManagerSingleton(); + } + + return instance; +} + + +class FaceTimeSessionManagerSingleton { + sessions: FaceTimeSession[] = []; + + findSession(callUuid: string): FaceTimeSession { + return this.sessions.find(session => (session.callUuid ?? '').toLowerCase() === callUuid.toLowerCase()); + } + + addSession(session: FaceTimeSession): boolean { + const existingSession = session?.callUuid ? this.findSession(session.callUuid) : null; + const isNew = !existingSession; + + // If the session exists, invalidate the old one and replace it with the new one. + if (existingSession) { + existingSession.update(session); + } else { + this.sessions.push(session); + } + + this.purgeOldSessions(); + return isNew; + } + + invalidateSession(callUuid: string) { + const session = this.findSession(callUuid); + if (session) { + session.invalidate(); + } + } + + clearSessions() { + for (const session of this.sessions) { + session.invalidate(); + } + + this.sessions = []; + } + + purgeOldSessions() { + // Purge sessions older than 3 hours & invalidated + const now = new Date().getTime(); + this.sessions = this.sessions.filter(session => { + return ( + session.status !== FaceTimeSessionStatus.DISCONNECTED || + now - session.createdAt.getTime() < 1000 * 60 * 60 * 3 + ); + }); + } +} diff --git a/packages/server/src/server/services/privateApi/constants.ts b/packages/server/src/server/api/privateApi/Constants.ts similarity index 100% rename from packages/server/src/server/services/privateApi/constants.ts rename to packages/server/src/server/api/privateApi/Constants.ts diff --git a/packages/server/src/server/services/privateApi/PrivateApiService.ts b/packages/server/src/server/api/privateApi/PrivateApiService.ts similarity index 56% rename from packages/server/src/server/services/privateApi/PrivateApiService.ts rename to packages/server/src/server/api/privateApi/PrivateApiService.ts index c6f83522..1f3a803a 100644 --- a/packages/server/src/server/services/privateApi/PrivateApiService.ts +++ b/packages/server/src/server/api/privateApi/PrivateApiService.ts @@ -3,10 +3,7 @@ import * as os from "os"; import * as net from "net"; import { Server } from "@server"; import { clamp, isEmpty, isNotEmpty } from "@server/helpers/utils"; -import { - TransactionPromise, - TransactionResult, -} from "@server/managers/transactionManager/transactionPromise"; +import { TransactionPromise, TransactionResult } from "@server/managers/transactionManager/transactionPromise"; import { TransactionManager } from "@server/managers/transactionManager"; import { MAX_PORT, MIN_PORT } from "./Constants"; @@ -18,14 +15,20 @@ import { PrivateApiHandle } from "./apis/PrivateApiHandle"; import { PrivateApiAttachment } from "./apis/PrivateApiAttachment"; import { PrivateApiMode, PrivateApiModeConstructor } from "./modes"; import { ProcessDylibMode } from "./modes/ProcessDylibMode"; - +import { PrivateApiPingEventHandler } from "./eventHandlers/PrivateApiPingEventHandler"; +import { PrivateApiFindMy } from "./apis/PrivateApiFindMy"; +import { PrivateApiAddressEventHandler } from "./eventHandlers/PrivateApiAddressEventHandler"; +import { PrivateApiFaceTimeStatusHandler } from "./eventHandlers/PrivateApiFaceTimeStatusHandler"; +import { PrivateApiCloud } from "./apis/PrivateApiCloud"; +import { PrivateApiFaceTime } from "./apis/PrivateApiFaceTime"; +import { LogLevel } from "electron-log"; export class PrivateApiService { server: net.Server; - helper: net.Socket; + clients: net.Socket[] = []; - restartCounter: number; + restartCounter = 0; transactionManager: TransactionManager; @@ -39,6 +42,12 @@ export class PrivateApiService { return clamp(MIN_PORT + os.userInfo().uid - 501, MIN_PORT, MAX_PORT); } + // Backwards compatibility getter. + // Eventually, we probably want to remove this alias + get helper(): boolean { + return this.clients.length > 0; + } + get message(): PrivateApiMessage { return new PrivateApiMessage(this); } @@ -55,26 +64,45 @@ export class PrivateApiService { return new PrivateApiAttachment(this); } + get findmy(): PrivateApiFindMy { + return new PrivateApiFindMy(this); + } + + get cloud(): PrivateApiCloud { + return new PrivateApiCloud(this); + } + + get facetime(): PrivateApiFaceTime { + return new PrivateApiFaceTime(this); + } + constructor() { this.restartCounter = 0; this.transactionManager = new TransactionManager(); // Register the event handlers this.eventHandlers = [ - new PrivateApiTypingEventHandler() + new PrivateApiTypingEventHandler(), + new PrivateApiPingEventHandler(), + new PrivateApiAddressEventHandler(), + new PrivateApiFaceTimeStatusHandler() ]; } + log(message: string, level: LogLevel = "info") { + Server().log(`[PrivateApiService] ${String(message)}`, level); + } + async start(): Promise { // Configure & start the listener - Server().log("Starting Private API Helper...", "debug"); + this.log("Starting Private API Helper Services...", "debug"); this.configureServer(); // we need to get the port to open the server on (to allow multiple users to use the bundle) // we'll base this off the users uid (a unique id for each user, starting from 501) // we'll subtract 501 to get an id starting at 0, incremented for each user // then we add this to the base port to get a unique port for the socket - Server().log(`Starting Socket server on port ${PrivateApiService.port}`); + this.log(`Starting socket server on port ${PrivateApiService.port}`); this.server.listen(PrivateApiService.port, "localhost", 511, () => { this.restartCounter = 0; }); @@ -84,11 +112,11 @@ export class PrivateApiService { async startPerMode(): Promise { try { - const mode = 'process-dylib' - if (mode === 'process-dylib') { + const mode = "process-dylib"; + if (mode === "process-dylib") { this.modeType = ProcessDylibMode; } else { - Server().log(`Invalid Private API mode: ${mode}`); + this.log(`Invalid Private API mode: ${mode}`); return; } @@ -96,48 +124,61 @@ export class PrivateApiService { this.mode = new this.modeType(); await this.mode.start(); } catch (ex: any) { - Server().log(`Failed to start Private API: ${ex?.message ?? String(ex)}`, "error"); + this.log(`Failed to start Private API: ${ex?.message ?? String(ex)}`, "error"); return; } } + addClient(client: net.Socket) { + this.clients.push(client); + this.log(`Added socket client (Total: ${this.clients.length})`); + } + + removeClient(client: net.Socket) { + const idx = this.clients.indexOf(client); + if (idx !== -1) { + this.clients.splice(idx, 1); + this.log(`Removed socket client (Total: ${this.clients.length})`); + } + } + configureServer() { this.server = net.createServer((socket: net.Socket) => { - this.helper = socket; - this.helper.setDefaultEncoding("utf8"); + this.addClient(socket); + socket.setDefaultEncoding("utf8"); - Server().log("Private API Helper connected!"); - this.setupListeners(); + this.log("Private API Helper connected!"); + this.setupListeners(socket); - this.helper.on("close", () => { - Server().log("Private API Helper disconnected!", "debug"); - this.helper = null; + socket.on("close", () => { + this.log("Private API Helper disconnected!", "debug"); + this.removeClient(socket); }); - this.helper.on("error", () => { - Server().log("An error occured in the BlueBubblesHelper connection! Closing...", "error"); - if (this.helper) this.helper.destroy(); + socket.on("error", err => { + this.log("An error occured in a BlueBubbles Private API Helper connection! Closing...", "debug"); + this.log(String(err), "debug"); + socket.destroy(); }); }); this.server.on("error", err => { - Server().log("An error occured in the TCP Socket! Restarting", "error"); - Server().log(err.toString(), "error"); + this.log("An error occured in the TCP Socket! Restarting", "error"); + this.log(err.toString(), "error"); if (this.restartCounter <= 5) { this.restartCounter += 1; this.start(); } else { - Server().log("Max restart count reached for Private API listener..."); + this.log("Max restart count reached for Private API listener..."); + this.stop(); } }); } - - async onEvent(eventRaw: string): Promise { if (eventRaw == null) { - Server().log(`Received null data from BlueBubblesHelper!`); + this.log(`Received null data from BlueBubblesHelper!`); return; } @@ -147,23 +188,23 @@ export class PrivateApiService { for (const event of uniqueEvents) { if (!event || event.trim().length === 0) continue; if (event == null) { - Server().log(`Failed to decode null BlueBubblesHelper data!`); + this.log(`Failed to decode null BlueBubblesHelper data!`); continue; } - // Server().log(`Received data from BlueBubblesHelper: ${event}`, "debug"); + // this.log(`Received data from BlueBubblesHelper: ${event}`, "debug"); let data; // Handle in a timeout so that we handle each event asyncronously try { data = JSON.parse(event); } catch (e) { - Server().log(`Failed to decode BlueBubblesHelper data! ${event}, ${e}`); + this.log(`Failed to decode BlueBubblesHelper data! ${event}, ${e}`); return; } if (data == null) { - Server().log("BlueBubblesHelper sent null data", "warn"); + this.log("BlueBubblesHelper sent null data", "warn"); return; } @@ -188,8 +229,8 @@ export class PrivateApiService { } } - setupListeners() { - this.helper.on("data", this.onEvent.bind(this)); + setupListeners(socket: net.Socket) { + socket.on("data", this.onEvent.bind(this)); } private readTransactionData(response: NodeJS.Dict) { @@ -223,7 +264,7 @@ export class PrivateApiService { } try { - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { const d: NodeJS.Dict = { action, data }; // If we have a transaction, set the transaction ID for the request @@ -231,19 +272,11 @@ export class PrivateApiService { d.transactionId = transaction.transactionId; } - // Write the request to the socket - const res = this.helper.write(`${JSON.stringify(d)}\n`, (err: Error) => { - if (err) { - Server().log(`Socket write error: ${err?.message ?? String(err)}`); - reject(err); - } + // For each ocket client, write data + this.writeToClients(`${JSON.stringify(d)}\n`).then(success => { + if (success) return resolve(); + reject(); }); - - if (!res) { - reject(new Error("Unable to write to TCP Socket.")); - } else { - resolve(res); - } }); // If we have a transaction, wait until the transaction is fulfilled to return @@ -251,39 +284,73 @@ export class PrivateApiService { return transaction.promise; } } catch (ex: any) { - Server().log(`${msg} ${ex?.message ?? ex}`, "debug"); + this.log(`${msg} ${ex?.message ?? ex}`, "debug"); } return null; } + private async writeToClients(data: string): Promise { + let success = false; + for (const client of this.clients) { + try { + await this.writeToClient(client, data); + success = true; + } catch { + // Do nothing + } + } + + // We are successful if at least one helper gets the message + return success; + } + + private async writeToClient(client: net.Socket, data: string): Promise { + return new Promise((resolve, reject) => { + client.write(data, (err: Error) => { + if (err) { + this.log(`Socket write error: ${err?.message ?? String(err)}`); + reject(err); + } else { + resolve(); + } + }); + }); + } + + destroySocketClients() { + for (const client of this.clients) { + if (client.destroyed) continue; + client.destroy(); + } + + this.clients = []; + } + async restart(): Promise { await this.stop(); await this.start(); } async stop() { - Server().log(`Stopping Private API Helper...`); + this.log(`Stopping Private API Helper...`); try { - if (this.helper && !this.helper.destroyed) { - this.helper.destroy(); - this.helper = null; - } + this.destroySocketClients(); } catch (ex: any) { - Server().log(`Failed to stop Private API Helper! Error: ${ex.toString()}`, 'debug'); + this.log(`Failed to stop Private API Helpers! Error: ${ex.toString()}`, "debug"); } try { if (this.server && this.server.listening) { - Server().log("Stopping Private API Helper...", "debug"); + this.log("Stopping Private API Helper...", "debug"); this.server.removeAllListeners(); this.server.close(); this.server = null; } } catch (ex: any) { - Server().log(`Failed to stop Private API Helper! Error: ${ex.toString()}`, 'debug'); + this.log(`Failed to stop Private API Helper! Error: ${ex.toString()}`, "debug"); } await this.mode?.stop(); diff --git a/packages/server/src/server/services/privateApi/apis/PrivateApiAttachment.ts b/packages/server/src/server/api/privateApi/apis/PrivateApiAttachment.ts similarity index 84% rename from packages/server/src/server/services/privateApi/apis/PrivateApiAttachment.ts rename to packages/server/src/server/api/privateApi/apis/PrivateApiAttachment.ts index 36d00866..e8e475a3 100644 --- a/packages/server/src/server/services/privateApi/apis/PrivateApiAttachment.ts +++ b/packages/server/src/server/api/privateApi/apis/PrivateApiAttachment.ts @@ -46,4 +46,10 @@ export class PrivateApiAttachment extends PrivateApiAction { request ); } + + async downloadPurged(guid: string): Promise { + const action = "download-purged-attachment"; + this.throwForNoMissingFields(action, [guid]); + return this.sendApiMessage(action, { attachmentGuid: guid }); + } } \ No newline at end of file diff --git a/packages/server/src/server/services/privateApi/apis/PrivateApiChat.ts b/packages/server/src/server/api/privateApi/apis/PrivateApiChat.ts similarity index 88% rename from packages/server/src/server/services/privateApi/apis/PrivateApiChat.ts rename to packages/server/src/server/api/privateApi/apis/PrivateApiChat.ts index 28cf3c9f..e041693c 100644 --- a/packages/server/src/server/services/privateApi/apis/PrivateApiChat.ts +++ b/packages/server/src/server/api/privateApi/apis/PrivateApiChat.ts @@ -109,6 +109,19 @@ export class PrivateApiChat extends PrivateApiAction { return this.sendApiMessage(action, { chatGuid }); } + async shouldOfferContactSharing(chatGuid: string): Promise { + const action = "should-offer-nickname-sharing"; + this.throwForNoMissingFields(action, [chatGuid]); + const request = new TransactionPromise(TransactionType.OTHER); + return this.sendApiMessage(action, { chatGuid }, request); + } + + async shareContactCard(chatGuid: string): Promise { + const action = "share-nickname"; + this.throwForNoMissingFields(action, [chatGuid]); + return this.sendApiMessage(action, { chatGuid }); + } + async leave(chatGuid: string): Promise { const action = "leave-chat"; this.throwForNoMissingFields(action, [chatGuid]); diff --git a/packages/server/src/server/api/privateApi/apis/PrivateApiCloud.ts b/packages/server/src/server/api/privateApi/apis/PrivateApiCloud.ts new file mode 100644 index 00000000..b01ca6aa --- /dev/null +++ b/packages/server/src/server/api/privateApi/apis/PrivateApiCloud.ts @@ -0,0 +1,28 @@ +import { + TransactionPromise, + TransactionResult, + TransactionType +} from "@server/managers/transactionManager/transactionPromise"; +import { PrivateApiAction } from "."; + + +export class PrivateApiCloud extends PrivateApiAction { + + async getAccountInfo(): Promise { + const action = "get-account-info"; + const request = new TransactionPromise(TransactionType.OTHER); + return this.sendApiMessage(action, null, request); + } + + async getContactCard(address: string = null): Promise { + const action = "get-nickname-info"; + const request = new TransactionPromise(TransactionType.OTHER); + return this.sendApiMessage(action, { address }, request); + } + + async modifyActiveAlias(alias: string): Promise { + const action = "modify-active-alias"; + const request = new TransactionPromise(TransactionType.OTHER); + return this.sendApiMessage(action, { alias }, request); + } +} \ No newline at end of file diff --git a/packages/server/src/server/api/privateApi/apis/PrivateApiFaceTime.ts b/packages/server/src/server/api/privateApi/apis/PrivateApiFaceTime.ts new file mode 100644 index 00000000..a959b679 --- /dev/null +++ b/packages/server/src/server/api/privateApi/apis/PrivateApiFaceTime.ts @@ -0,0 +1,38 @@ +import { + TransactionPromise, + TransactionResult, + TransactionType +} from "@server/managers/transactionManager/transactionPromise"; +import { PrivateApiAction } from "."; + + +export class PrivateApiFaceTime extends PrivateApiAction { + + async answerCall(uuid: string): Promise { + const action = "answer-call"; + return this.sendApiMessage(action, { callUUID: uuid }); + } + + async leaveCall(uuid: string): Promise { + const action = "leave-call"; + return this.sendApiMessage(action, { callUUID: uuid }); + } + + async generateLink(uuid: string = null): Promise { + const action = "generate-link"; + const request = new TransactionPromise(TransactionType.OTHER); + + const args: Record = {}; + args["callUUID"] = uuid ?? null; + + return this.sendApiMessage(action, args, request); + } + + async admitParticipant(conversationUuid: string, handleUuid: string): Promise { + const action = "admit-pending-member"; + return this.sendApiMessage(action, { + conversationUUID: conversationUuid, + handleUUID: handleUuid + }); + } +} \ No newline at end of file diff --git a/packages/server/src/server/api/privateApi/apis/PrivateApiFindMy.ts b/packages/server/src/server/api/privateApi/apis/PrivateApiFindMy.ts new file mode 100644 index 00000000..d9cef0be --- /dev/null +++ b/packages/server/src/server/api/privateApi/apis/PrivateApiFindMy.ts @@ -0,0 +1,17 @@ +import { Server } from "@server"; +import { + TransactionPromise, + TransactionResult, + TransactionType +} from "@server/managers/transactionManager/transactionPromise"; +import { PrivateApiAction } from "."; + + +export class PrivateApiFindMy extends PrivateApiAction { + + async getFriendsLocations(): Promise { + const action = "findmy-friends"; + const request = new TransactionPromise(TransactionType.FIND_MY); + return this.sendApiMessage(action, null, request); + } +} \ No newline at end of file diff --git a/packages/server/src/server/services/privateApi/apis/PrivateApiHandle.ts b/packages/server/src/server/api/privateApi/apis/PrivateApiHandle.ts similarity index 100% rename from packages/server/src/server/services/privateApi/apis/PrivateApiHandle.ts rename to packages/server/src/server/api/privateApi/apis/PrivateApiHandle.ts diff --git a/packages/server/src/server/services/privateApi/apis/PrivateApiMessage.ts b/packages/server/src/server/api/privateApi/apis/PrivateApiMessage.ts similarity index 82% rename from packages/server/src/server/services/privateApi/apis/PrivateApiMessage.ts rename to packages/server/src/server/api/privateApi/apis/PrivateApiMessage.ts index c9dbbb26..bd96673b 100644 --- a/packages/server/src/server/services/privateApi/apis/PrivateApiMessage.ts +++ b/packages/server/src/server/api/privateApi/apis/PrivateApiMessage.ts @@ -1,4 +1,3 @@ -import { Server } from "@server"; import { TransactionPromise, TransactionResult, @@ -6,6 +5,7 @@ import { } from "@server/managers/transactionManager/transactionPromise"; import { PrivateApiAction } from "."; import type { ValidTapback, ValidRemoveTapback } from "@server/types"; +import { isMinCatalina } from "@server/env"; export class PrivateApiMessage extends PrivateApiAction { @@ -17,24 +17,28 @@ export class PrivateApiMessage extends PrivateApiAction { subject: string = null, effectId: string = null, selectedMessageGuid: string = null, - partIndex = 0 + partIndex = 0, + ddScan = false ): Promise { const action = "send-message"; this.throwForNoMissingFields(action, [chatGuid, message]); const request = new TransactionPromise(TransactionType.MESSAGE); - return this.sendApiMessage( - "send-message", - { - chatGuid, - subject, - message, - attributedBody, - effectId, - selectedMessageGuid, - partIndex - }, - request - ); + + const data: any = { + chatGuid, + subject, + message, + attributedBody, + effectId, + selectedMessageGuid, + partIndex + }; + + if (isMinCatalina) { + data.ddScan = ddScan ? 1 : 0 + } + + return this.sendApiMessage("send-message", data, request); } async sendMultipart( @@ -44,24 +48,28 @@ export class PrivateApiMessage extends PrivateApiAction { subject: string = null, effectId: string = null, selectedMessageGuid: string = null, - partIndex = 0 + partIndex = 0, + ddScan = false ): Promise { const action = "send-multipart"; this.throwForNoMissingFields(action, [chatGuid, parts]); const request = new TransactionPromise(TransactionType.MESSAGE); - return this.sendApiMessage( - action, - { - chatGuid, - subject, - parts, - attributedBody, - effectId, - selectedMessageGuid, - partIndex - }, - request - ); + + const data: any = { + chatGuid, + subject, + parts, + attributedBody, + effectId, + selectedMessageGuid, + partIndex + }; + + if (isMinCatalina) { + data.ddScan = ddScan ? 1 : 0 + } + + return this.sendApiMessage(action, data, request); } async react( diff --git a/packages/server/src/server/services/privateApi/apis/index.ts b/packages/server/src/server/api/privateApi/apis/index.ts similarity index 100% rename from packages/server/src/server/services/privateApi/apis/index.ts rename to packages/server/src/server/api/privateApi/apis/index.ts diff --git a/packages/server/src/server/api/privateApi/eventHandlers/PrivateApiAddressEventHandler.ts b/packages/server/src/server/api/privateApi/eventHandlers/PrivateApiAddressEventHandler.ts new file mode 100644 index 00000000..44c50ba2 --- /dev/null +++ b/packages/server/src/server/api/privateApi/eventHandlers/PrivateApiAddressEventHandler.ts @@ -0,0 +1,26 @@ +import { Server } from "@server"; +import { IMESSAGE_ALIAS_REMOVED } from "@server/events"; +import { EventData, PrivateApiEventHandler } from "."; + + +export class PrivateApiAddressEventHandler implements PrivateApiEventHandler { + + types: string[] = ["alias-removed"]; + + cache: Record> = {}; + + async handle(data: EventData) { + if (data.event === 'alias-removed') { + await this.handleDeregistration(data); + } + } + + async handleDeregistration(data: any) { + const address = data.__kIMAccountAliasesRemovedKey ?? data.data?.__kIMAccountAliasesRemovedKey ?? null; + if (!address) { + return Server().log('iMessage address deregistration event received, but no address was found!', 'warn'); + } + + Server().emitMessage(IMESSAGE_ALIAS_REMOVED, { address }, "high", true); + } +} \ No newline at end of file diff --git a/packages/server/src/server/api/privateApi/eventHandlers/PrivateApiFaceTimeStatusHandler.ts b/packages/server/src/server/api/privateApi/eventHandlers/PrivateApiFaceTimeStatusHandler.ts new file mode 100644 index 00000000..b0b519eb --- /dev/null +++ b/packages/server/src/server/api/privateApi/eventHandlers/PrivateApiFaceTimeStatusHandler.ts @@ -0,0 +1,90 @@ +import { Server } from "@server"; +import { FT_CALL_STATUS_CHANGED, INCOMING_FACETIME } from "@server/events"; +import { EventData, PrivateApiEventHandler } from "."; +import { HandleResponse } from "@server/types"; +import { slugifyAddress } from "@server/helpers/utils"; +import { HandleSerializer } from "@server/api/serializers/HandleSerializer"; +import { isMinMonterey } from "@server/env"; +import { FaceTimeSessionManager } from "@server/api/lib/facetime/FacetimeSessionManager"; +import { FaceTimeSession, FaceTimeSessionStatus, callStatusMap } from "@server/api/lib/facetime/FaceTimeSession"; + +type FaceTimeStatusData = { + uuid: string; + status_id: number; + status: string; + ended_error: string; + ended_reason: string; + address: string; + handle?: HandleResponse; + image_url: string; + is_outgoing: boolean; + is_audio: boolean; + is_video: boolean; + url?: string | null; +}; + +export class PrivateApiFaceTimeStatusHandler implements PrivateApiEventHandler { + types: string[] = ["ft-call-status-changed"]; + + // The commeneted out handler at the bottom should be used in 1.9.0 + async handle(data: EventData) { + // Ignore calls that are not incoming + if (data.data.call_status !== FaceTimeSessionStatus.INCOMING) return; + + // stringify to maintain backwards compat + const output = JSON.stringify({ + caller: data.data.handle.value, + timestamp: new Date().getTime() + }); + + Server().emitMessage(INCOMING_FACETIME, output, "high", true, true); + } + + // async handle(data: EventData) { + // const session = FaceTimeSession.fromEvent(data.data); + // const isNew = FaceTimeSessionManager().addSession(session) + + // // Don't do anything for outgoing calls + // if ([3].includes(data.data.call_status)) return; + + // // When a call is answered, we don't need to emit an event + // if (data.data.call_status === FaceTimeSessionStatus.ANSWERED) { + // Server().log(`FaceTime call answered (Call UUID: ${data.data.call_uuid})`); + // return; + // } + + // // When a call is incoming, update the session + // // Don't emit an event if it was an existing session + // if (data.data.call_status === FaceTimeSessionStatus.INCOMING) { + // Server().log( + // `Incoming FaceTime call from ${data.data.handle.value} (Call UUID: ${data.data.call_uuid})`); + // if (!isNew) return; + // } + + // // If the call was disonnected, update the session, but still emit an event + // if (data.data.call_status === FaceTimeSessionStatus.DISCONNECTED && data.data.handle) { + // Server().log(`FaceTime call disconnected with ${data.data.handle.value}`); + // } + + // const addr = slugifyAddress(data.data.handle.value); + // const [handle, _] = await Server().iMessageRepo.getHandles({ address: addr, limit: 1 }); + + // // Build a payload to be sent out to clients. + // // We just alias some of the data to make it easier to work with. + // const output: FaceTimeStatusData = { + // uuid: data.data.call_uuid, + // status: callStatusMap[data.data.call_status] ?? "unknown", + // status_id: data.data.call_status, + // ended_error: data.data.ended_error, + // ended_reason: data.data.ended_reason, + // address: data.data.handle.value, + // handle: handle[0] ? await HandleSerializer.serialize({ handle: handle[0] }) : null, + // image_url: data.data.image_url ?? null, + // is_outgoing: data.data.is_outgoing ?? false, + // is_audio: data.data.is_sending_audio ?? false, + // is_video: data.data.is_sending_video ?? true, + // }; + + // Server().emitMessage(FT_CALL_STATUS_CHANGED, output, "high", true); + // } +} diff --git a/packages/server/src/server/services/privateApi/eventHandlers/PrivateApiPingEventHandler.ts b/packages/server/src/server/api/privateApi/eventHandlers/PrivateApiPingEventHandler.ts similarity index 50% rename from packages/server/src/server/services/privateApi/eventHandlers/PrivateApiPingEventHandler.ts rename to packages/server/src/server/api/privateApi/eventHandlers/PrivateApiPingEventHandler.ts index 50c8f5ac..32666fb0 100644 --- a/packages/server/src/server/services/privateApi/eventHandlers/PrivateApiPingEventHandler.ts +++ b/packages/server/src/server/api/privateApi/eventHandlers/PrivateApiPingEventHandler.ts @@ -1,12 +1,12 @@ import { Server } from "@server"; -import { PrivateApiEventHandler } from "."; +import { PrivateApiEventHandler, EventData } from "."; export class PrivateApiPingEventHandler implements PrivateApiEventHandler { types: string[] = ["ping"]; - async handle(_: any) { - Server().log("Private API Helper connected!"); + async handle(_: EventData) { + Server().log("Received Ping from Private API Helper!"); } } \ No newline at end of file diff --git a/packages/server/src/server/services/privateApi/eventHandlers/PrivateApiTypingEventHandler.ts b/packages/server/src/server/api/privateApi/eventHandlers/PrivateApiTypingEventHandler.ts similarity index 77% rename from packages/server/src/server/services/privateApi/eventHandlers/PrivateApiTypingEventHandler.ts rename to packages/server/src/server/api/privateApi/eventHandlers/PrivateApiTypingEventHandler.ts index 441657eb..b65c2fae 100644 --- a/packages/server/src/server/services/privateApi/eventHandlers/PrivateApiTypingEventHandler.ts +++ b/packages/server/src/server/api/privateApi/eventHandlers/PrivateApiTypingEventHandler.ts @@ -1,19 +1,22 @@ import { Server } from "@server"; import { TYPING_INDICATOR } from "@server/events"; -import { PrivateApiEventHandler } from "."; +import { PrivateApiEventHandler, EventData } from "."; export class PrivateApiTypingEventHandler implements PrivateApiEventHandler { - types: string[] = ["typing", "stopped-typing"]; + types: string[] = ["started-typing", "typing", "stopped-typing"]; cache: Record> = {}; - async handle(event: any) { - const display = event === "started-typing"; - const guid = event.guid; + async handle(data: EventData) { + const display = data.event === "started-typing"; + const guid = data.guid; let shouldEmit = false; + // Ignore typing events from group chats (they aren't "proper") + if (guid.includes(';+;')) return; + // If the guid hasn't been seen before, we should emit the event const now = new Date().getTime(); if (!Object.keys(this.cache).includes(guid)) { diff --git a/packages/server/src/server/api/privateApi/eventHandlers/index.ts b/packages/server/src/server/api/privateApi/eventHandlers/index.ts new file mode 100644 index 00000000..bbbf1615 --- /dev/null +++ b/packages/server/src/server/api/privateApi/eventHandlers/index.ts @@ -0,0 +1,13 @@ +export type EventData = { + event: string; + guid?: string; + data?: any; + [key: string]: any; +}; + +export interface PrivateApiEventHandler { + + types: string[]; + + handle(data: EventData): Promise; +} \ No newline at end of file diff --git a/packages/server/src/server/services/privateApi/modes/MacForgeMode.ts b/packages/server/src/server/api/privateApi/modes/MacForgeMode.ts similarity index 90% rename from packages/server/src/server/services/privateApi/modes/MacForgeMode.ts rename to packages/server/src/server/api/privateApi/modes/MacForgeMode.ts index d489bf5e..8fa946c3 100644 --- a/packages/server/src/server/services/privateApi/modes/MacForgeMode.ts +++ b/packages/server/src/server/api/privateApi/modes/MacForgeMode.ts @@ -2,11 +2,11 @@ import CompareVersions from "compare-versions"; import cpr from "recursive-copy"; import { parse as ParsePlist } from "plist"; -import { isMinBigSur, isMinMonterey } from "@server/helpers/utils"; +import { isMinBigSur, isMinMonterey } from "@server/env"; import { PrivateApiMode } from "."; import { Server } from "@server"; import { FileSystem } from "@server/fileSystem"; -import { restartMessages } from "@server/api/v1/apple/scripts"; +import { restartMessages } from "@server/api/apple/scripts"; type BundleStatus = { @@ -136,20 +136,27 @@ export class MacForgeMode extends PrivateApiMode { ]; for (const pluginPath of opts) { - if (!fs.existsSync(pluginPath)) continue; + if (!fs.existsSync(pluginPath)) { + Server().log(`Plugin directory not found: ${pluginPath}`, 'debug'); + continue; + } const remotePath = path.join(pluginPath, "BlueBubblesHelper.bundle"); try { // If the remote bundle doesn't exist, we just need to write it + Server().log(`Looking for deprecated Helper Bundle at: ${remotePath}`, 'debug'); if (fs.existsSync(remotePath)) { - fs.rm(remotePath, { recursive: true, force: true }); + await fs.rm(remotePath, { recursive: true, force: true }); + Server().log(` -> Removed deprecated Helper`, 'debug'); + } else { + Server().log(' -> Helper Bundle not found', 'debug'); } } catch (ex: any) { Server().log(( `Failed to remove MacForge bundle at, "${remotePath}": ` + `Please manually remove it to prevent conflicts` - ), 'warn'); + ), 'debug'); } } } diff --git a/packages/server/src/server/api/privateApi/modes/ProcessDylibMode.ts b/packages/server/src/server/api/privateApi/modes/ProcessDylibMode.ts new file mode 100644 index 00000000..af620308 --- /dev/null +++ b/packages/server/src/server/api/privateApi/modes/ProcessDylibMode.ts @@ -0,0 +1,56 @@ +import "zx/globals"; +import { PrivateApiMode } from "."; +import { MacForgeMode } from "./MacForgeMode"; +import { DylibPlugin } from "./dylibPlugins"; +import { MessagesDylibPlugin } from "./dylibPlugins/MessagesDylibPlugin"; +import { FaceTimeDylibPlugin } from "./dylibPlugins/FaceTimeDylibPlugin"; +import { Server } from "@server"; +import { isMinBigSur } from "@server/env"; + +export class ProcessDylibMode extends PrivateApiMode { + static plugins: DylibPlugin[] = [ + new MessagesDylibPlugin("Messages Helper"), + ...isMinBigSur ? [new FaceTimeDylibPlugin("FaceTime Helper")] : [] + ]; + + static async install() { + for (const plugin of ProcessDylibMode.plugins) { + try { + plugin.locateDependencies(); + } catch (e: any) { + Server().log(`Failed to locate dependencies for ${plugin.name}: ${e?.message ?? String(e)}`); + } + } + + await MacForgeMode.uninstall(); + } + + static async uninstall() { + // Nothing to do here + } + + async start() { + this.isStopping = false; + + // Start the dylib process + for (const plugin of ProcessDylibMode.plugins) { + try { + // Don't await this. This is a blocking call. + plugin.injectPlugin(); + } catch (e: any) { + Server().log(`Failed to inject ${plugin.name} DYLIB: ${e?.message ?? String(e)}`); + } + } + } + + async stop() { + this.isStopping = true; + + // Stop the dylib process + for (const plugin of ProcessDylibMode.plugins) { + await plugin.stop(); + } + + this.isStopping = false; + } +} diff --git a/packages/server/src/server/api/privateApi/modes/dylibPlugins/FaceTimeDylibPlugin.ts b/packages/server/src/server/api/privateApi/modes/dylibPlugins/FaceTimeDylibPlugin.ts new file mode 100644 index 00000000..8b8877a5 --- /dev/null +++ b/packages/server/src/server/api/privateApi/modes/dylibPlugins/FaceTimeDylibPlugin.ts @@ -0,0 +1,14 @@ +import "zx/globals"; +import { isMinBigSur, isMinMonterey } from "@server/env"; +import { DylibPlugin } from "."; +import { FileSystem } from "@server/fileSystem"; + +const macVer = isMinMonterey ? "macos11" : isMinBigSur ? "macos11" : "macos10"; + +export class FaceTimeDylibPlugin extends DylibPlugin { + parentApp = "FaceTime"; + + get dylibPath() { + return path.join(FileSystem.resources, "private-api", macVer, "BlueBubblesFaceTimeHelper.dylib"); + } +} diff --git a/packages/server/src/server/api/privateApi/modes/dylibPlugins/MessagesDylibPlugin.ts b/packages/server/src/server/api/privateApi/modes/dylibPlugins/MessagesDylibPlugin.ts new file mode 100644 index 00000000..bcf953a5 --- /dev/null +++ b/packages/server/src/server/api/privateApi/modes/dylibPlugins/MessagesDylibPlugin.ts @@ -0,0 +1,14 @@ +import "zx/globals"; +import { isMinBigSur, isMinMonterey } from "@server/env"; +import { DylibPlugin } from "."; +import { FileSystem } from "@server/fileSystem"; + +const macVer = isMinMonterey ? "macos11" : isMinBigSur ? "macos11" : "macos10"; + +export class MessagesDylibPlugin extends DylibPlugin { + parentApp = "Messages"; + + get dylibPath() { + return path.join(FileSystem.resources, "private-api", macVer, "BlueBubblesHelper.dylib"); + } +} diff --git a/packages/server/src/server/services/privateApi/modes/ProcessDylibMode.ts b/packages/server/src/server/api/privateApi/modes/dylibPlugins/index.ts similarity index 53% rename from packages/server/src/server/services/privateApi/modes/ProcessDylibMode.ts rename to packages/server/src/server/api/privateApi/modes/dylibPlugins/index.ts index b76b66d6..526d61d9 100644 --- a/packages/server/src/server/services/privateApi/modes/ProcessDylibMode.ts +++ b/packages/server/src/server/api/privateApi/modes/dylibPlugins/index.ts @@ -1,17 +1,17 @@ import "zx/globals"; import fs from "fs"; -import { isMinBigSur, isMinMonterey, waitMs } from "@server/helpers/utils"; -import { PrivateApiMode } from "."; +import { waitMs } from "@server/helpers/utils"; import { Server } from "@server"; -import { FileSystem } from "@server/fileSystem"; -import { stopMessages } from "@server/api/v1/apple/scripts"; import { ProcessPromise } from "zx"; -import { MacForgeMode } from "./MacForgeMode"; +import { FileSystem } from "@server/fileSystem"; +import { hideApp } from "@server/api/apple/scripts"; -const macVer = isMinMonterey ? "macos11" : isMinBigSur ? "macos11" : "macos10"; +export abstract class DylibPlugin { + name: string = null; + parentApp: string = null; -export class ProcessDylibMode extends PrivateApiMode { + isStopping = false; dylibProcess: ProcessPromise; @@ -19,16 +19,23 @@ export class ProcessDylibMode extends PrivateApiMode { dylibLastErrorTime = 0; - static get dylbiPath() { - return path.join(FileSystem.resources, "private-api", macVer, "BlueBubblesHelper.dylib"); + abstract get dylibPath(): string; + + constructor(name: string) { + this.name = name; + } + + async stopParentProcess(): Promise { + Server().log(`Killing process: ${this.parentApp}`, "debug"); + await FileSystem.killProcess(this.parentApp); } - static get messagesPath() { + get parentProcessPath(): string | null { // Paths are different on Pre/Post Catalina. // We are gonna test for both just in case an app was installed prior to the OS upgrade. const possiblePaths = [ - "/System/Applications/Messages.app/Contents/MacOS/Messages", - "/Applications/Messages.app/Contents/MacOS/Messages" + `/System/Applications/${this.parentApp}.app/Contents/MacOS/${this.parentApp}`, + `/Applications/${this.parentApp}.app/Contents/MacOS/${this.parentApp}` ]; // Return the first path that exists @@ -39,41 +46,37 @@ export class ProcessDylibMode extends PrivateApiMode { return null; } - - static async install() { - if (!fs.existsSync(ProcessDylibMode.dylbiPath)) { - throw new Error("Unable to locate embedded Private API DYLIB! Please reinstall the app."); - } - if (!this.messagesPath) { - throw new Error("Unable to locate Messages.app! Please give the BlueBubbles Server Full Disk Access."); + locateDependencies() { + if (!fs.existsSync(this.dylibPath)) { + throw new Error(`Unable to locate embedded ${this.name} DYLIB! Please reinstall the app.`); } - await MacForgeMode.uninstall(); - } - - static async uninstall() { - // Nothing to do here - } - - async start() { - // Call a different function so this can properly be awaited/returned. - this.manageProcess(); + if (!fs.existsSync(this.parentProcessPath)) { + throw new Error( + `Unable to locate ${this.name} parent process! Please give the BlueBubbles Server Full Disk Access.` + ); + } } - async manageProcess() { - const messagesPath = ProcessDylibMode.messagesPath; + async injectPlugin() { + Server().log(`Injecting ${this.name} DYLIB...`, "debug"); // Clear the markers this.dylibFailureCounter = 0; this.dylibLastErrorTime = 0; // If there are 5 failures in a row, we'll stop trying to start it + const parentPath = this.parentProcessPath; + if (!parentPath) { + throw new Error(`Unable to locate ${this.name} parent process!`); + } + while (this.dylibFailureCounter < 5) { try { // Stop the running Messages app try { - await FileSystem.executeAppleScript(stopMessages()); + await this.stopParentProcess(); await waitMs(1000); } catch { // Ignore. This is most likely due to an osascript error. @@ -82,7 +85,13 @@ export class ProcessDylibMode extends PrivateApiMode { // Execute shell command to start the dylib. // eslint-disable-next-line max-len - this.dylibProcess = $`DYLD_INSERT_LIBRARIES=${ProcessDylibMode.dylbiPath} ${messagesPath}`; + this.dylibProcess = $`DYLD_INSERT_LIBRARIES=${this.dylibPath} ${this.parentProcessPath}`; + + // HIde the app after 5 seconds + setTimeout(() => { + this.hideApp(); + }, 4000); + await this.dylibProcess; } catch (ex: any) { if (this.isStopping) return; @@ -97,13 +106,25 @@ export class ProcessDylibMode extends PrivateApiMode { this.dylibFailureCounter += 1; this.dylibLastErrorTime = Date.now(); if (this.dylibFailureCounter >= 5) { - Server().log(`Failed to start dylib after 5 tries: ${ex?.message ?? String(ex)}`, "error"); + Server().log( + `Failed to start ${this.name} DYLIB after 5 tries: ${ex?.message ?? String(ex)}`, + "error" + ); } } } if (this.dylibFailureCounter >= 5) { - Server().log("Failed to start Private API DYLIB 3 times in a row, giving up...", "error"); + Server().log(`Failed to start ${this.name} DYLIB 3 times in a row, giving up...`, "error"); + } + } + + async hideApp() { + try { + await FileSystem.executeAppleScript(hideApp(this.parentApp)); + } catch (ex) { + console.log(ex); + // Don't do anything } } @@ -112,7 +133,11 @@ export class ProcessDylibMode extends PrivateApiMode { return new Promise((resolve, _) => { // Catch the error so the promise doesn't throw a no-catch error. - this.dylibProcess.catch(() => { /** Do nothing */ }).finally(resolve); + this.dylibProcess + .catch(() => { + /** Do nothing */ + }) + .finally(resolve); }); } @@ -123,12 +148,12 @@ export class ProcessDylibMode extends PrivateApiMode { try { this.dylibFailureCounter = 0; if (this.dylibProcess != null && !(this.dylibProcess?.child?.killed ?? false)) { - Server().log("Killing BlueBubblesHelper DYLIB...", "debug"); + Server().log(`Killing ${this.name} DYLIB...`, "debug"); await this.dylibProcess.kill(9); killedDylib = true; } } catch (ex) { - Server().log(`Failed to stop Private API Helper! Error: ${ex.toString()}`, 'debug'); + Server().log(`Failed to stop ${this.name} DYLIB! Error: ${ex.toString()}`, "debug"); } // Wait for the dylib to die @@ -138,4 +163,4 @@ export class ProcessDylibMode extends PrivateApiMode { this.isStopping = false; } -} \ No newline at end of file +} diff --git a/packages/server/src/server/services/privateApi/modes/index.ts b/packages/server/src/server/api/privateApi/modes/index.ts similarity index 100% rename from packages/server/src/server/services/privateApi/modes/index.ts rename to packages/server/src/server/api/privateApi/modes/index.ts diff --git a/packages/server/src/server/api/v1/serializers/AttachmentSerializer.ts b/packages/server/src/server/api/serializers/AttachmentSerializer.ts similarity index 97% rename from packages/server/src/server/api/v1/serializers/AttachmentSerializer.ts rename to packages/server/src/server/api/serializers/AttachmentSerializer.ts index 5fef7cab..91013aed 100644 --- a/packages/server/src/server/api/v1/serializers/AttachmentSerializer.ts +++ b/packages/server/src/server/api/serializers/AttachmentSerializer.ts @@ -90,11 +90,10 @@ export class AttachmentSerializer { } } } catch (ex: any) { - console.log(ex); - Server().log(`Could not read file [${fPath}]: ${ex.message}`, "error"); + Server().log(`Could not read file [${fPath}]: ${ex?.message ?? String(ex)}`, "error"); } } else { - console.warn("Attachment hasn't been downloaded yet!"); + Server().log("Attachment hasn't been downloaded yet!", "debug"); } let output: AttachmentResponse = { diff --git a/packages/server/src/server/api/v1/serializers/ChatSerializer.ts b/packages/server/src/server/api/serializers/ChatSerializer.ts similarity index 100% rename from packages/server/src/server/api/v1/serializers/ChatSerializer.ts rename to packages/server/src/server/api/serializers/ChatSerializer.ts diff --git a/packages/server/src/server/api/v1/serializers/HandleSerializer.ts b/packages/server/src/server/api/serializers/HandleSerializer.ts similarity index 100% rename from packages/server/src/server/api/v1/serializers/HandleSerializer.ts rename to packages/server/src/server/api/serializers/HandleSerializer.ts diff --git a/packages/server/src/server/api/v1/serializers/MessageSerializer.ts b/packages/server/src/server/api/serializers/MessageSerializer.ts similarity index 98% rename from packages/server/src/server/api/v1/serializers/MessageSerializer.ts rename to packages/server/src/server/api/serializers/MessageSerializer.ts index 2d601e09..adfd1621 100644 --- a/packages/server/src/server/api/v1/serializers/MessageSerializer.ts +++ b/packages/server/src/server/api/serializers/MessageSerializer.ts @@ -1,5 +1,6 @@ import { Server } from "@server"; -import { isEmpty, isMinHighSierra, isMinMonterey, isMinVentura, isNotEmpty } from "@server/helpers/utils"; +import { isEmpty, isNotEmpty } from "@server/helpers/utils"; +import { isMinHighSierra, isMinMonterey, isMinVentura } from "@server/env"; import { HandleResponse, MessageResponse } from "@server/types"; import { AttachmentSerializer } from "./AttachmentSerializer"; import { ChatSerializer } from "./ChatSerializer"; diff --git a/packages/server/src/server/api/v1/serializers/constants.ts b/packages/server/src/server/api/serializers/constants.ts similarity index 100% rename from packages/server/src/server/api/v1/serializers/constants.ts rename to packages/server/src/server/api/serializers/constants.ts diff --git a/packages/server/src/server/api/v1/serializers/types.ts b/packages/server/src/server/api/serializers/types.ts similarity index 100% rename from packages/server/src/server/api/v1/serializers/types.ts rename to packages/server/src/server/api/serializers/types.ts diff --git a/packages/server/src/server/api/v1/types/alertTypes.ts b/packages/server/src/server/api/types/alertTypes.ts similarity index 100% rename from packages/server/src/server/api/v1/types/alertTypes.ts rename to packages/server/src/server/api/types/alertTypes.ts diff --git a/packages/server/src/server/api/v1/types/index.ts b/packages/server/src/server/api/types/index.ts similarity index 96% rename from packages/server/src/server/api/v1/types/index.ts rename to packages/server/src/server/api/types/index.ts index 9c698841..109de348 100644 --- a/packages/server/src/server/api/v1/types/index.ts +++ b/packages/server/src/server/api/types/index.ts @@ -11,6 +11,7 @@ export type SendMessageParams = { selectedMessageGuid?: string; tempGuid?: string; partIndex?: number; + ddScan?: boolean; }; export type SendMessagePrivateApiParams = { @@ -21,6 +22,7 @@ export type SendMessagePrivateApiParams = { effectId?: string; selectedMessageGuid?: string; partIndex?: number; + ddScan?: boolean; }; export type SendAttachmentPrivateApiParams = { @@ -79,4 +81,5 @@ export type SendMultipartTextParams = { selectedMessageGuid?: string; partIndex?: number; parts: Record[]; + ddScan?: boolean; }; diff --git a/packages/server/src/server/api/v1/caches/apiContactsCache.ts b/packages/server/src/server/api/v1/caches/apiContactsCache.ts deleted file mode 100644 index 83fee154..00000000 --- a/packages/server/src/server/api/v1/caches/apiContactsCache.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { getContactPermissionStatus } from "@server/utils/PermissionUtils"; -import { ContactInterface } from "../interfaces/contactInterface"; - -const contacts = require("node-mac-contacts"); - -export class ApiContactsCache { - contacts: any[] | null = null; - - getApiContacts() { - // If we aren't authorized, return an empty array without setting this.contacts. - // This way, if a permission is changed from Denied -> Authorized, we can still - // load the contacts because this.contacts === null - const authorized = getContactPermissionStatus() === "Authorized"; - if (!authorized) return []; - - // If we are authorized, fetch the contacts and return them - if (this.contacts === null) this.loadApiContacts(); - return this.contacts; - } - - loadApiContacts(force = false) { - // If we've already loaded the contacts, don't reload them. - // This is due to a memory leak in v1.4.0 of node-mac-contacts - if (!force && this.contacts !== null) return; - - this.loadContacts(); - } - - private loadContacts() { - // Request ALL extra properties so that we can cache and deliver them all - this.contacts = contacts.getAllContacts(ContactInterface.apiExtraProperties); - } -} diff --git a/packages/server/src/server/databases/imessage/entity/Attachment.ts b/packages/server/src/server/databases/imessage/entity/Attachment.ts index 9054ce17..d5a5ac9c 100644 --- a/packages/server/src/server/databases/imessage/entity/Attachment.ts +++ b/packages/server/src/server/databases/imessage/entity/Attachment.ts @@ -1,8 +1,9 @@ import { Entity, PrimaryGeneratedColumn, Column, ManyToMany, JoinTable } from "typeorm"; import { BooleanTransformer } from "@server/databases/transformers/BooleanTransformer"; -import { AppleDateTransformer } from "@server/databases/transformers/AppleDateTransformer"; +import { MessagesDateTransformer } from "@server/databases/transformers/MessagesDateTransformer"; import { Message } from "@server/databases/imessage/entity/Message"; -import { isMinSierra, isMinHighSierra, isEmpty } from "@server/helpers/utils"; +import { isEmpty } from "@server/helpers/utils"; +import { isMinSierra, isMinHighSierra } from "@server/env"; import { conditional } from "conditional-decorator"; import * as mime from "mime-types"; import { FileSystem } from "@server/fileSystem"; @@ -28,7 +29,7 @@ export class Attachment { type: "integer", name: "created_date", default: 0, - transformer: AppleDateTransformer + transformer: MessagesDateTransformer }) createdDate: Date; @@ -36,7 +37,7 @@ export class Attachment { type: "integer", name: "start_date", default: 0, - transformer: AppleDateTransformer + transformer: MessagesDateTransformer }) startDate: Date; diff --git a/packages/server/src/server/databases/imessage/entity/Chat.ts b/packages/server/src/server/databases/imessage/entity/Chat.ts index 27c16a1d..8f899b45 100644 --- a/packages/server/src/server/databases/imessage/entity/Chat.ts +++ b/packages/server/src/server/databases/imessage/entity/Chat.ts @@ -3,8 +3,8 @@ import { BooleanTransformer } from "@server/databases/transformers/BooleanTransf import { Handle } from "@server/databases/imessage/entity/Handle"; import { Message } from "@server/databases/imessage/entity/Message"; import { AttributedBodyTransformer } from "@server/databases/transformers/AttributedBodyTransformer"; -import { isMinHighSierra } from "@server/helpers/utils"; -import { AppleDateTransformer } from "@server/databases/transformers/AppleDateTransformer"; +import { isMinHighSierra } from "@server/env"; +import { MessagesDateTransformer } from "@server/databases/transformers/MessagesDateTransformer"; import { conditional } from "conditional-decorator"; @Entity("chat") @@ -72,7 +72,7 @@ export class Chat { Column({ name: "last_read_message_timestamp", type: "date", - transformer: AppleDateTransformer, + transformer: MessagesDateTransformer, default: 0 }) ) diff --git a/packages/server/src/server/databases/imessage/entity/Message.ts b/packages/server/src/server/databases/imessage/entity/Message.ts index aa9a5966..0ea7faa1 100644 --- a/packages/server/src/server/databases/imessage/entity/Message.ts +++ b/packages/server/src/server/databases/imessage/entity/Message.ts @@ -2,21 +2,23 @@ import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinTable, JoinColum import { conditional } from "conditional-decorator"; import { BooleanTransformer } from "@server/databases/transformers/BooleanTransformer"; -import { AppleDateTransformer } from "@server/databases/transformers/AppleDateTransformer"; +import { MessagesDateTransformer } from "@server/databases/transformers/MessagesDateTransformer"; import { MessageTypeTransformer } from "@server/databases/transformers/MessageTypeTransformer"; import { Handle } from "@server/databases/imessage/entity/Handle"; import { Chat } from "@server/databases/imessage/entity/Chat"; import { Attachment } from "@server/databases/imessage/entity/Attachment"; import { isEmpty, + sanitizeStr +} from "@server/helpers/utils"; +import { isMinBigSur, isMinCatalina, isMinHighSierra, isMinMonterey, isMinSierra, - isMinVentura, - sanitizeStr -} from "@server/helpers/utils"; + isMinVentura +} from "@server/env"; import { NSAttributedString } from "node-typedstream"; import { AttributedBodyTransformer } from "@server/databases/transformers/AttributedBodyTransformer"; import { AttributedBodyUtils } from "@server/utils/AttributedBodyUtils"; @@ -154,7 +156,7 @@ export class Message { name: "date", type: "date", nullable: true, - transformer: AppleDateTransformer + transformer: MessagesDateTransformer }) dateCreated: Date; @@ -162,7 +164,7 @@ export class Message { name: "date_read", type: "date", nullable: true, - transformer: AppleDateTransformer + transformer: MessagesDateTransformer }) dateRead: Date; @@ -170,7 +172,7 @@ export class Message { name: "date_delivered", type: "date", nullable: true, - transformer: AppleDateTransformer + transformer: MessagesDateTransformer }) dateDelivered: Date; @@ -348,7 +350,7 @@ export class Message { @Column({ name: "date_played", type: "integer", - transformer: AppleDateTransformer, + transformer: MessagesDateTransformer, default: 0 }) datePlayed: Date; @@ -474,7 +476,7 @@ export class Message { Column({ name: "time_expressive_send_played", type: "integer", - transformer: AppleDateTransformer, + transformer: MessagesDateTransformer, default: 0 }) ) @@ -548,7 +550,7 @@ export class Message { Column({ name: "date_retracted", type: "date", - transformer: AppleDateTransformer, + transformer: MessagesDateTransformer, default: 0 }) ) @@ -559,7 +561,7 @@ export class Message { Column({ name: "date_edited", type: "date", - transformer: AppleDateTransformer, + transformer: MessagesDateTransformer, default: 0 }) ) diff --git a/packages/server/src/server/databases/imessage/helpers/dateUtil.ts b/packages/server/src/server/databases/imessage/helpers/dateUtil.ts index 18af5ca3..9488f0ed 100644 --- a/packages/server/src/server/databases/imessage/helpers/dateUtil.ts +++ b/packages/server/src/server/databases/imessage/helpers/dateUtil.ts @@ -18,13 +18,16 @@ export const get2001Time = (): number => { * * @param timestamp The seconds-since-2001 */ -export const getDateUsing2001 = (timestamp: number): Date => { - if (timestamp === 0) return null; +export const getDateUsing2001 = (timestamp: number, multiplier = MULTIPLIER): Date => { + if (timestamp === 0 || timestamp == null) return null; try { let ts = get2001Time(); - if (!osVersion || CompareVersions(osVersion, "10.13.0") >= 0) ts += timestamp / MULTIPLIER; - else ts += timestamp * 1000; + if (!osVersion || CompareVersions(osVersion, "10.13.0") >= 0) { + ts += timestamp / multiplier; + } else { + ts += timestamp * 1000; + } return new Date(ts); } catch (e: any) { @@ -33,6 +36,19 @@ export const getDateUsing2001 = (timestamp: number): Date => { } }; +export const getCocoaDate = (timestamp: number): Date => { + if (timestamp === 0 || timestamp == null) return null; + + try { + let ts = get2001Time(); + ts += timestamp * 1000; + return new Date(ts); + } catch (e: any) { + console.log(e.message); + return null; + } +}; + /** * Converts a date object to a seconds-since-2001 timestamp * @@ -52,3 +68,16 @@ export const convertDateTo2001Time = (date: Date): number => { return null; } }; + +export const convertDateToCocoaTime = (date: Date): number => { + if (date === null) return 0; + + try { + let ts = date.getTime() - get2001Time(); + ts /= 1000; + return ts; + } catch (e: any) { + console.log(e.message); + return null; + } +}; diff --git a/packages/server/src/server/databases/imessage/index.ts b/packages/server/src/server/databases/imessage/index.ts index 9305fae7..f947167a 100644 --- a/packages/server/src/server/databases/imessage/index.ts +++ b/packages/server/src/server/databases/imessage/index.ts @@ -7,7 +7,8 @@ import { Chat } from "@server/databases/imessage/entity/Chat"; import { Handle } from "@server/databases/imessage/entity/Handle"; import { Message } from "@server/databases/imessage/entity/Message"; import { Attachment } from "@server/databases/imessage/entity/Attachment"; -import { isMinHighSierra, isMinVentura, isNotEmpty } from "@server/helpers/utils"; +import { isNotEmpty } from "@server/helpers/utils"; +import { isMinHighSierra, isMinVentura } from "@server/env"; /** * A repository class to facilitate pulling information from the iMessage database diff --git a/packages/server/src/server/databases/imessage/listeners/outgoingMessageListener.ts b/packages/server/src/server/databases/imessage/listeners/outgoingMessageListener.ts index a3238656..7568aa67 100644 --- a/packages/server/src/server/databases/imessage/listeners/outgoingMessageListener.ts +++ b/packages/server/src/server/databases/imessage/listeners/outgoingMessageListener.ts @@ -1,6 +1,7 @@ import { Server } from "@server"; import { DBWhereItem } from "@server/databases/imessage/types"; -import { isMinMonterey, isNotEmpty } from "@server/helpers/utils"; +import { isNotEmpty } from "@server/helpers/utils"; +import { isMinMonterey } from "@server/env"; import { MessageChangeListener } from "./messageChangeListener"; import type { Message } from "../entity/Message"; diff --git a/packages/server/src/server/databases/notificationCenter/NotiicationCenterRepository.ts b/packages/server/src/server/databases/notificationCenter/NotiicationCenterRepository.ts new file mode 100644 index 00000000..1cb7293f --- /dev/null +++ b/packages/server/src/server/databases/notificationCenter/NotiicationCenterRepository.ts @@ -0,0 +1,124 @@ +import fs from "fs"; +import { Brackets, DataSource } from "typeorm"; +import { Record } from "./entity/Record"; +import { App } from "./entity/App"; +import { FileSystem } from "@server/fileSystem"; +import { Server } from "@server"; +import { DBWhereItem } from "../imessage/types"; +import { isNotEmpty } from "@server/helpers/utils"; + +let repo: NotificationCenterDatabase = null; +export const NotificationCenterDB = () => { + if (repo) return repo; + repo = new NotificationCenterDatabase(); + return repo; +}; + +/** + * A repository class to facilitate pulling information from the iMessage database + */ +class NotificationCenterDatabase { + db: DataSource = null; + + error: any = null; + + initPromise: Promise = null; + + constructor() { + this.db = null; + this.error = null; + + this.initPromise = this.initialize(); + this.initPromise.catch(err => { + this.error = err; + Server().log(`Failed to initialize the NotificationCenter database! Error: ${err}`, "error"); + }); + } + + async getDbPath(): Promise { + const basePath = await FileSystem.getUserConfDir(); + return `${basePath}com.apple.notificationcenter/db2/db`; + } + + async dbExists(): Promise { + const path = await this.getDbPath(); + return fs.existsSync(path); + } + + /** + * Creates a connection to the iMessage database + */ + async initialize() { + // Reset the error flag + this.error = null; + + // Check if the DB path exists + const dbPath = await this.getDbPath(); + if (!this.dbExists()) { + throw new Error("NotificationCenter database does not exist!"); + } + + if (this.db) { + if (!this.db.isInitialized) { + this.db = await this.db.initialize(); + } + + return this.db; + } + + this.db = new DataSource({ + name: "NotificationCenter", + type: "better-sqlite3", + database: dbPath, + entities: [Record, App] + }); + + this.db = await this.db.initialize(); + return this.db; + } + + async getRecords({ + limit = 100, + offset = 0, + sort = "DESC", + sortField = "deliveredDate", + withApp = true, + where = [] + }: { + limit?: number; + offset?: number; + sort?: "ASC" | "DESC"; + sortField?: string; + withApp?: boolean; + where?: DBWhereItem[]; + } = {} + ): Promise<[Record[], number]> { + if (this.initPromise) await this.initPromise; + if (this.error) throw this.error; + if (!this.db) throw new Error("Database is not initialized!"); + const repo = this.db.getRepository(Record); + const query = repo.createQueryBuilder("record"); + + if (withApp) { + query.leftJoinAndSelect("record.app", "app"); + } + + // Add any custom WHERE clauses + if (isNotEmpty(where)) { + query.andWhere( + new Brackets(qb => { + for (const item of where) { + qb.andWhere(item.statement, item.args); + } + }) + ); + } + + query + .orderBy(`record.${sortField}`, sort) + .take(limit) + .skip(offset); + + return await query.getManyAndCount(); + } +} diff --git a/packages/server/src/server/databases/notificationCenter/entity/App.ts b/packages/server/src/server/databases/notificationCenter/entity/App.ts new file mode 100644 index 00000000..dc7dd5c0 --- /dev/null +++ b/packages/server/src/server/databases/notificationCenter/entity/App.ts @@ -0,0 +1,18 @@ +import { Entity, Column, PrimaryColumn, OneToOne } from "typeorm"; +import { Record } from "./Record"; + + +@Entity("app") +export class App { + @PrimaryColumn({ name: "app_id", type: "integer" }) + id: number; + + @Column({ name: "identifier", type: "varchar" }) + identifier: string; + + @Column({ name: "badge", type: "integer", nullable: true, default: null }) + badge: number; + + @OneToOne(() => Record, record => record.app) + record: Record; +} diff --git a/packages/server/src/server/databases/notificationCenter/entity/Record.ts b/packages/server/src/server/databases/notificationCenter/entity/Record.ts new file mode 100644 index 00000000..d845df3d --- /dev/null +++ b/packages/server/src/server/databases/notificationCenter/entity/Record.ts @@ -0,0 +1,99 @@ +import { Entity, Column, PrimaryColumn, OneToOne, JoinColumn } from "typeorm"; +import { BooleanTransformer } from "@server/databases/transformers/BooleanTransformer"; +import { App } from "./App"; +import { AttributedBodyTransformer } from "@server/databases/transformers/AttributedBodyTransformer"; +import { NSAttributedString } from "node-typedstream"; +import { CocoaDateTransformer } from "@server/databases/transformers/CocoaDateTransformer"; + + +type RecordData = { + styl: number; + intl: boolean; + app: string; + uuid: Buffer; + date: number; + srce: Buffer; + orig: number; + req: { + body: string; + titl: string; + cate?: string; + thre?: string; + smac?: number; + durl?: string; + subt?: string; + iden?: string; + [key: string]: any; + } +}; + +@Entity("record") +export class Record { + @PrimaryColumn({ name: "rec_id", type: "integer" }) + id: number; + + @Column({ name: "app_id", type: "integer" }) + appId: number; + + @OneToOne(() => App, app => app.record) + @JoinColumn({ name: "app_id", referencedColumnName: "id" }) + app: App; + + @Column({ + name: "uuid", + type: "blob", + nullable: false, + }) + uuid: Blob; + + @Column({ + name: "data", + type: "blob", + nullable: false, + transformer: AttributedBodyTransformer + }) + data: RecordData[] | null; + + @Column({ + name: "request_date", + type: "real", + nullable: true, + transformer: CocoaDateTransformer + }) + requestDate: Date; + + @Column({ + name: "request_last_date", + type: "real", + nullable: true, + transformer: CocoaDateTransformer + }) + lastRequestDate: Date; + + @Column({ + name: "delivered_date", + type: "real", + nullable: true, + transformer: CocoaDateTransformer + }) + deliveredDate: Date; + + @Column({ + name: "presented", + type: "integer", + transformer: BooleanTransformer, + default: 1 + }) + presented: boolean; + + @Column({ name: "style", type: "integer", default: 1 }) + style: boolean; + + @Column({ + name: "snooze_fire_date", + type: "real", + nullable: true, + transformer: CocoaDateTransformer + }) + snoozeFireDate: Date; +} diff --git a/packages/server/src/server/databases/server/constants.ts b/packages/server/src/server/databases/server/constants.ts index 043efab8..3b7fc954 100644 --- a/packages/server/src/server/databases/server/constants.ts +++ b/packages/server/src/server/databases/server/constants.ts @@ -23,8 +23,12 @@ export const DEFAULT_DB_ITEMS: { [key: string]: () => any } = { use_oled_dark_mode: () => 0, db_poll_interval: () => 1000, dock_badge: () => 1, - facetime_detection: () => 0, start_minimized: () => 0, headless: () => 0, - private_api_mode: (): 'process-dylib' => 'process-dylib' + disable_gpu: () => 0, + private_api_mode: (): "process-dylib" => "process-dylib", + // String because we don't handle actual integers well. + // That needs to change... at another time. + // 0.0 to prevent parsing as a boolean + start_delay: () => '0.0', }; diff --git a/packages/server/src/server/databases/transformers/CocoaDateTransformer.ts b/packages/server/src/server/databases/transformers/CocoaDateTransformer.ts new file mode 100644 index 00000000..86bf81d8 --- /dev/null +++ b/packages/server/src/server/databases/transformers/CocoaDateTransformer.ts @@ -0,0 +1,7 @@ +import { ValueTransformer } from "typeorm"; +import { convertDateToCocoaTime, getCocoaDate } from "@server/databases/imessage/helpers/dateUtil"; + +export const CocoaDateTransformer: ValueTransformer = { + from: dbValue => getCocoaDate(dbValue), + to: entityValue => convertDateToCocoaTime(entityValue) +}; diff --git a/packages/server/src/server/databases/transformers/AppleDateTransformer.ts b/packages/server/src/server/databases/transformers/MessagesDateTransformer.ts similarity index 81% rename from packages/server/src/server/databases/transformers/AppleDateTransformer.ts rename to packages/server/src/server/databases/transformers/MessagesDateTransformer.ts index 963d4cc0..6dfde52e 100644 --- a/packages/server/src/server/databases/transformers/AppleDateTransformer.ts +++ b/packages/server/src/server/databases/transformers/MessagesDateTransformer.ts @@ -1,7 +1,7 @@ import { ValueTransformer } from "typeorm"; import { convertDateTo2001Time, getDateUsing2001 } from "@server/databases/imessage/helpers/dateUtil"; -export const AppleDateTransformer: ValueTransformer = { +export const MessagesDateTransformer: ValueTransformer = { from: dbValue => getDateUsing2001(dbValue), to: entityValue => convertDateTo2001Time(entityValue) }; diff --git a/packages/server/src/server/env.ts b/packages/server/src/server/env.ts new file mode 100644 index 00000000..b4f6058f --- /dev/null +++ b/packages/server/src/server/env.ts @@ -0,0 +1,10 @@ +import * as macosVersion from "macos-version"; + + +export const isMinVentura = macosVersion.isGreaterThanOrEqualTo("13.0"); +export const isMinMonterey = macosVersion.isGreaterThanOrEqualTo("12.0"); +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"); diff --git a/packages/server/src/server/events.ts b/packages/server/src/server/events.ts index 635fbab3..60a3d950 100644 --- a/packages/server/src/server/events.ts +++ b/packages/server/src/server/events.ts @@ -26,3 +26,5 @@ export const SETTINGS_BACKUP_UPDATED = "settings-backup-updated"; export const THEME_BACKUP_CREATED = "theme-backup-created"; export const THEME_BACKUP_DELETED = "theme-backup-deleted"; export const THEME_BACKUP_UPDATED = "theme-backup-updated"; +export const IMESSAGE_ALIAS_REMOVED = "imessage-alias-removed"; +export const FT_CALL_STATUS_CHANGED = "ft-call-status-changed"; diff --git a/packages/server/src/server/fileSystem/index.ts b/packages/server/src/server/fileSystem/index.ts index 5bfd8ce2..df70e69d 100644 --- a/packages/server/src/server/fileSystem/index.ts +++ b/packages/server/src/server/fileSystem/index.ts @@ -12,12 +12,11 @@ import { parseMetadataString, isNotEmpty, isEmpty, - safeTrim, - isMinMonterey -} from "@server/helpers/utils"; + safeTrim} from "@server/helpers/utils"; +import { isMinMonterey } from "@server/env"; import { Attachment } from "@server/databases/imessage/entity/Attachment"; -import { startMessages } from "../api/v1/apple/scripts"; +import { startMessages } from "../api/apple/scripts"; import { AudioMetadata, AudioMetadataKeys, @@ -124,6 +123,10 @@ export class FileSystem { return fcmServer ?? path.join(FileSystem.fcmDir, "server.json"); } + public static async getUserConfDir(): Promise { + return (await FileSystem.execShellCommand(`/usr/bin/getconf DARWIN_USER_DIR`)).trim(); + } + /** * Sets up all required directories and then, writes the scripts * to the scripts directory @@ -692,4 +695,8 @@ export class FileSystem { return addresses; } + + static async killProcess(name: string): Promise { + await FileSystem.execShellCommand(`killall "${name}"`); + } } diff --git a/packages/server/src/server/helpers/utils.ts b/packages/server/src/server/helpers/utils.ts index 857fd0d0..4518f3a1 100644 --- a/packages/server/src/server/helpers/utils.ts +++ b/packages/server/src/server/helpers/utils.ts @@ -1,6 +1,5 @@ /* eslint-disable no-bitwise */ import { NativeImage } from "electron"; -import * as macosVersion from "macos-version"; import { encode as blurhashEncode } from "blurhash"; import { Server } from "@server"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -10,16 +9,8 @@ import { FileSystem } from "@server/fileSystem"; import { Handle } from "@server/databases/imessage/entity/Handle"; import { Chat } from "@server/databases/imessage/entity/Chat"; import { Message } from "@server/databases/imessage/entity/Message"; -import { invisibleMediaChar } from "@server/services/httpService/constants"; -import { ContactInterface } from "@server/api/v1/interfaces/contactInterface"; - -export const isMinVentura = macosVersion.isGreaterThanOrEqualTo("13.0"); -export const isMinMonterey = macosVersion.isGreaterThanOrEqualTo("12.0"); -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"); +import { invisibleMediaChar } from "@server/api/http/constants"; +import { ContactInterface } from "@server/api/interfaces/contactInterface"; export const clamp = (num: number, min: number, max: number) => Math.min(Math.max(num, min), max); @@ -517,3 +508,11 @@ export const resultAwaiter = async ({ return data; }; + +export const getObjectAsString = (value: any): string => { + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} diff --git a/packages/server/src/server/index.ts b/packages/server/src/server/index.ts index 5cc3dae2..2a43ad03 100644 --- a/packages/server/src/server/index.ts +++ b/packages/server/src/server/index.ts @@ -1,8 +1,7 @@ /* eslint-disable class-methods-use-this */ // Dependency Imports import { app, BrowserWindow, nativeTheme, systemPreferences, dialog } from "electron"; -import fs from "fs"; -import ServerLog from "electron-log"; +import ServerLog, { LogLevel } from "electron-log"; import process from "process"; import path from "path"; import os from "os"; @@ -26,7 +25,6 @@ import { Message } from "@server/databases/imessage/entity/Message"; // Service Imports import { - HttpService, FCMService, CaffeinateService, NgrokService, @@ -37,31 +35,21 @@ import { UpdateService, CloudflareService, WebhookService, - FacetimeService, ScheduledMessagesService, OauthService } from "@server/services"; import { EventCache } from "@server/eventCache"; -import { runTerminalScript, openSystemPreferences } from "@server/api/v1/apple/scripts"; +import { runTerminalScript, openSystemPreferences } from "@server/api/apple/scripts"; -import { ActionHandler } from "./api/v1/apple/actions"; -import { - insertChatParticipants, - isEmpty, - isMinBigSur, - isMinHighSierra, - isMinMojave, - isMinMonterey, - isMinSierra, - isNotEmpty, - waitMs -} from "./helpers/utils"; +import { ActionHandler } from "./api/apple/actions"; +import { insertChatParticipants, isEmpty, isNotEmpty, waitMs } from "./helpers/utils"; +import { isMinBigSur, isMinHighSierra, isMinMojave, isMinMonterey, isMinSierra } from "./env"; import { Proxy } from "./services/proxyServices/proxy"; -import { PrivateApiService } from "./services/privateApi/PrivateApiService"; +import { PrivateApiService } from "./api/privateApi/PrivateApiService"; import { OutgoingMessageManager } from "./managers/outgoingMessageManager"; import { requestContactPermission } from "./utils/PermissionUtils"; -import { AlertsInterface } from "./api/v1/interfaces/alertsInterface"; -import { MessageSerializer } from "./api/v1/serializers/MessageSerializer"; +import { AlertsInterface } from "./api/interfaces/alertsInterface"; +import { MessageSerializer } from "./api/serializers/MessageSerializer"; import { CHAT_READ_STATUS_CHANGED, GROUP_ICON_CHANGED, @@ -77,13 +65,14 @@ import { import { ChatUpdateListener } from "./databases/imessage/listeners/chatUpdateListener"; import { ChangeListener } from "./databases/imessage/listeners/changeListener"; import { Chat } from "./databases/imessage/entity/Chat"; +import { HttpService } from "./api/http"; +import { Alert } from "./databases/server/entity"; +import { getStartDelay } from "./utils/ConfigUtils"; const findProcess = require("find-process"); const osVersion = macosVersion(); -const facetimeServiceEnabled = true; - // Set the log format const logFormat = "[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}"; ServerLog.transports.console.format = logFormat; @@ -135,8 +124,6 @@ class BlueBubblesServer extends EventEmitter { fcm: FCMService; - facetime: FacetimeService; - networkChecker: NetworkService; caffeinate: CaffeinateService; @@ -232,7 +219,6 @@ class BlueBubblesServer extends EventEmitter { this.httpService = null; this.privateApi = null; this.fcm = null; - this.facetime = null; this.caffeinate = null; this.networkChecker = null; this.queue = null; @@ -271,7 +257,7 @@ class BlueBubblesServer extends EventEmitter { * @param message The message to print * @param type The log type */ - log(message: any, type?: "log" | "error" | "warn" | "debug") { + log(message: any, type?: LogLevel) { switch (type) { case "error": ServerLog.error(message); @@ -286,7 +272,7 @@ class BlueBubblesServer extends EventEmitter { AlertsInterface.create("warn", message); this.notificationCount += 1; break; - case "log": + case "info": default: ServerLog.log(message); } @@ -317,7 +303,7 @@ class BlueBubblesServer extends EventEmitter { loadSettingsFromArgs() { // This flag is true by default. If it's set to false, all // config values will not be stored in teh DB. - const persist = this.args['persist-config'] ?? true; + const persist = this.args["persist-config"] ?? true; this.loadSettingsFromDict(this.args, persist); } @@ -338,15 +324,16 @@ class BlueBubblesServer extends EventEmitter { // Make sure the value matches the type of the config value const configValue = this.repo.config[normalizedKey]; if (configValue != null && typeof configValue !== typeof value) { - Server().log(( + Server().log( `[ENV] Invalid type for config value "${normalizedKey}"! ` + - `Expected ${typeof configValue}, got ${typeof value}` - ), "warn"); + `Expected ${typeof configValue}, got ${typeof value}`, + "warn" + ); continue; } // Set the value - Server().log(`[ENV] Setting config value ${normalizedKey} to ${value} (persist=${persist})`, "debug") + Server().log(`[ENV] Setting config value ${normalizedKey} to ${value} (persist=${persist})`, "debug"); this.repo.setConfig(normalizedKey, value, persist); } } @@ -376,8 +363,14 @@ class BlueBubblesServer extends EventEmitter { this.log("Starting IPC Listeners.."); IPCService.startIpcListeners(); + const startDelay: number = getStartDelay(); + if (startDelay > 0) { + this.log(`Delaying server startup by ${startDelay} seconds`); + await waitMs(startDelay * 1000); + } + // Let listeners know the server is ready - this.emit('ready'); + this.emit("ready"); // Do some pre-flight checks // Make sure settings are correct and all things are a go @@ -445,7 +438,6 @@ class BlueBubblesServer extends EventEmitter { } } - async initServices(): Promise { this.initFcm(); @@ -489,13 +481,6 @@ class BlueBubblesServer extends EventEmitter { this.log(`Failed to start Webhook service! ${ex.message}`, "error"); } - try { - this.log("Initializing Facetime service..."); - this.facetime = new FacetimeService(); - } catch (ex: any) { - this.log(`Failed to start Facetime service! ${ex.message}`, "error"); - } - try { this.log("Initializing Scheduled Messages Service..."); this.scheduledMessages = new ScheduledMessagesService(); @@ -519,7 +504,7 @@ class BlueBubblesServer extends EventEmitter { // Only start the oauth service if the tutorial isn't done const tutorialDone = this.repo.getConfig("tutorial_is_done") as boolean; - const oauthToken = this.args['oauth-token']; + const oauthToken = this.args["oauth-token"]; this.oauthService.initialize(); // If the user passed an oauth token, use that to setup the project @@ -551,14 +536,6 @@ class BlueBubblesServer extends EventEmitter { this.log(`Failed to start Scheduled Messages service! ${ex.message}`, "error"); } - try { - if (facetimeServiceEnabled) { - this.startFacetimeListener(); - } - } catch (ex: any) { - this.log(`Failed to start Facetime service! ${ex.message}`, "error"); - } - const privateApiEnabled = this.repo.getConfig("enable_private_api") as boolean; if (privateApiEnabled) { this.log("Starting Private API Helper listener..."); @@ -571,21 +548,6 @@ class BlueBubblesServer extends EventEmitter { } } - startFacetimeListener() { - this.log("Starting Facetime service..."); - this.facetime.listen().catch(ex => { - if (ex.message.includes("assistive access")) { - this.log( - "Failed to start Facetime service! Please enable Accessibility permissions " + - "for BlueBubbles in System Preferences > Security & Privacy > Privacy > Accessibility", - "error" - ); - } else { - this.log(`Failed to start Facetime service! ${ex.message}`, "error"); - } - }); - } - async stopServices(): Promise { this.isStopping = true; this.log("Stopping services..."); @@ -627,12 +589,6 @@ class BlueBubblesServer extends EventEmitter { this.log(`Failed to stop OAuth service! ${ex?.message ?? ex}`, "error"); } - try { - this.facetime?.stop(); - } catch (ex: any) { - this.log(`Failed to stop Facetime service! ${ex?.message ?? ex}`, "error"); - } - try { this.scheduledMessages?.stop(); } catch (ex: any) { @@ -726,7 +682,7 @@ class BlueBubblesServer extends EventEmitter { // Load notification count try { this.log("Initializing alert service..."); - const alerts = (await AlertsInterface.find()).filter(item => !item.isRead); + const alerts = (await AlertsInterface.find()).filter((item: Alert) => !item.isRead); this.notificationCount = alerts.length; } catch (ex: any) { this.log("Failed to get initial notification count. Skipping.", "warn"); @@ -798,6 +754,11 @@ class BlueBubblesServer extends EventEmitter { // Set the dock icon according to the config this.setDockIcon(); + const noGpu = Server().repo.getConfig("disable_gpu") ?? false; + if (noGpu && !this.args["disable-gpu"]) { + this.relaunch(); + } + // Start minimized if enabled const startMinimized = Server().repo.getConfig("start_minimized") as boolean; if (startMinimized) { @@ -939,7 +900,16 @@ class BlueBubblesServer extends EventEmitter { // Check for contact permissions const contactStatus = await requestContactPermission(); - this.log(`Contacts authorization status: ${contactStatus}`, "debug"); + if (contactStatus === "Denied") { + this.log( + "Contacts authorization status is denied! You may need to manually " + + "allow BlueBubbles to access your contacts.", + "debug" + ); + } else { + this.log(`Contacts authorization status: ${contactStatus}`, "debug"); + } + this.log("Finished post-start checks..."); } @@ -1002,10 +972,10 @@ class BlueBubblesServer extends EventEmitter { // If it's not initialized, we need to initialize it. // Initializing it will also set the server URL if (!this.fcm.hasInitialized) { - Server().log('Initializing FCM for server URL update from config change', 'debug'); + Server().log("Initializing FCM for server URL update from config change", "debug"); await this.fcm.start(); } else { - Server().log('Dispatching server URL update from config change', 'debug'); + Server().log("Dispatching server URL update from config change", "debug"); await this.fcm.setServerUrl(); } } @@ -1068,15 +1038,6 @@ class BlueBubblesServer extends EventEmitter { } } - // Handle change in facetime service toggle - if (prevConfig.facetime_detection !== nextConfig.facetime_detection) { - if (nextConfig.facetime_detection) { - this.startFacetimeListener(); - } else { - this.facetime.stop(); - } - } - // If the password changes, we need to make sure the clients connected to the socket are kicked. if (prevConfig.password !== nextConfig.password) { this.httpService.kickClients(); @@ -1629,7 +1590,7 @@ class BlueBubblesServer extends EventEmitter { }; // If we are persisting configs, remove any flags that are stored in the DB - const persist = this.args['persist-config'] ?? true; + const persist = this.args["persist-config"] ?? true; if (persist) { const configKeys = Object.keys(Server().repo.config); for (const key of configKeys) { @@ -1647,21 +1608,25 @@ class BlueBubblesServer extends EventEmitter { } } - // Remove the oauth-token flag & value if it exists. removeArg("oauth-token"); + // Remove the disable-gpu flag and re-add it if enabled + removeArg("disable-gpu"); + const noGpu = Server().repo.getConfig("disable_gpu") ?? false; + if (noGpu) { + args.push("--disable-gpu"); + } + return args; } async relaunch({ - headless = null, exit = true, quit = false }: { - headless?: boolean | null; - exit?: boolean, - quit?: boolean + exit?: boolean; + quit?: boolean; } = {}) { this.isRestarting = true; @@ -1697,7 +1662,7 @@ class BlueBubblesServer extends EventEmitter { relaunchArgs = [process.execPath, ...relaunchArgs]; // Kick off the restart script - FileSystem.executeAppleScript(runTerminalScript(relaunchArgs.join(' '))); + FileSystem.executeAppleScript(runTerminalScript(relaunchArgs.join(" "))); // Exit the current instance app.exit(0); diff --git a/packages/server/src/server/managers/transactionManager/transactionPromise.ts b/packages/server/src/server/managers/transactionManager/transactionPromise.ts index 271c3ff2..a9fa69b2 100644 --- a/packages/server/src/server/managers/transactionManager/transactionPromise.ts +++ b/packages/server/src/server/managers/transactionManager/transactionPromise.ts @@ -6,7 +6,9 @@ export enum TransactionType { CHAT, MESSAGE, ATTACHMENT, - HANDLE + HANDLE, + FIND_MY, + OTHER } export type TransactionResult = { diff --git a/packages/server/src/server/services/facetimeService/index.ts b/packages/server/src/server/services/facetimeService/index.ts deleted file mode 100644 index 9913ee56..00000000 --- a/packages/server/src/server/services/facetimeService/index.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { - checkForIncomingFacetime10, - checkForIncomingFacetime11, - checkForIncomingFacetime13 -} from "@server/api/v1/apple/scripts"; -import { Server } from "@server/index"; -import { FileSystem } from "@server/fileSystem"; -import { isMinBigSur, isMinVentura, isNotEmpty, waitMs } from "@server/helpers/utils"; -import { INCOMING_FACETIME } from "@server/events"; - -export class FacetimeService { - isStopping = false; - - isRunning = false; - - serviceAwaiter: Promise = null; - - isGettingCall = false; - - hadPreviousCall = false; - - notificationSent = false; - - incomingCallListener: NodeJS.Timeout; - - flush() { - this.isRunning = false; - this.isStopping = false; - this.serviceAwaiter = null; - this.isGettingCall = false; - this.hadPreviousCall = false; - this.notificationSent = false; - } - - async listen({ delay = 30000 } = {}): Promise { - if (this.isRunning) { - Server().log("Facetime listener already running"); - return this.serviceAwaiter; - } - - if (delay && delay > 0) { - Server().log(`Delaying FaceTime listener start by ${delay} ms...`); - await waitMs(delay); - } - - Server().log("Starting FaceTime listener..."); - this.flush(); - - this.isRunning = true; - this.serviceAwaiter = new Promise((resolve, reject) => { - const serviceLoop = async () => { - if (this.isStopping) { - return resolve(); - } - - // Check for call applescript - let facetimeCallIncoming; - let hasErrored = false; - - try { - if (isMinVentura) { - facetimeCallIncoming = ( - await FileSystem.executeAppleScript(checkForIncomingFacetime13()) - ).replace("\n", ""); // If on macos 13 - } else if (isMinBigSur) { - facetimeCallIncoming = ( - await FileSystem.executeAppleScript(checkForIncomingFacetime11()) - ).replace("\n", ""); // If on macos 11 and 12 - } else { - facetimeCallIncoming = ( - await FileSystem.executeAppleScript(checkForIncomingFacetime10()) - ).replace("\n", ""); // If on macos 10 and below - } - } catch (e: any) { - if ((e?.message ?? "").includes("assistive access")) { - Server().log("Facetime listener detected an assistive access error"); - this.flush(); - return reject(e); - } else if ((e?.message ?? "").includes("can't open default scripting component")) { - Server().log("Facetime listener detected an osascript component error"); - this.flush(); - return reject(e); - } else { - Server().log(`Facetime listener AppleScript error: ' + ${e.message}`); - hasErrored = true; - } - } - - if (!hasErrored) { - // Ignore the notification of a missed Facetime - if ( - facetimeCallIncoming === "now" || - facetimeCallIncoming.endsWith("ago") || - facetimeCallIncoming.endsWith("AM") || - facetimeCallIncoming.endsWith("PM") - ) { - facetimeCallIncoming = ""; - } - - // Set the flag if we have a valid incoming call - if (isNotEmpty(facetimeCallIncoming)) { - this.isGettingCall = true; - } else { - this.isGettingCall = false; - } - - // If we haven't sent a notification to the clients, send it. - if (this.isGettingCall && !this.notificationSent) { - Server().log(`Incoming facetime call from ${facetimeCallIncoming}`); - this.notificationSent = true; - - const jsonData = JSON.stringify({ - caller: facetimeCallIncoming, - timestamp: new Date().getTime() - }); - - Server().emitMessage(INCOMING_FACETIME, jsonData, "high"); - } - - // Clear the notification sent variable - if (!this.isGettingCall && this.notificationSent && this.hadPreviousCall) { - this.notificationSent = false; - } - - this.hadPreviousCall = this.isGettingCall; - } - - setTimeout(serviceLoop, 5000); - }; - - setTimeout(serviceLoop, 0); - }); - - return this.serviceAwaiter; - } - - async stop() { - Server().log("Stopping FaceTime listener..."); - if (this.serviceAwaiter && this.isRunning) { - this.isStopping = true; - await this.serviceAwaiter; - } - - this.flush(); - } -} diff --git a/packages/server/src/server/services/fcmService/index.ts b/packages/server/src/server/services/fcmService/index.ts index 473bd4f0..0a8792c9 100644 --- a/packages/server/src/server/services/fcmService/index.ts +++ b/packages/server/src/server/services/fcmService/index.ts @@ -4,7 +4,7 @@ import { FileSystem } from "@server/fileSystem"; import { RulesFile } from "firebase-admin/lib/security-rules/security-rules"; import { App } from "firebase-admin/app"; import axios from "axios"; -import { resultRetryer } from "@server/helpers/utils"; +import { resultRetryer, waitMs } from "@server/helpers/utils"; const AppName = "BlueBubbles"; @@ -209,12 +209,19 @@ export class FCMService { 4 ); - const db = admin.app(AppName).database(); + const db = FCMService.getApp().database(); // Set read rules await db.setRules(source); } + clearLastValues() { + this.lastRestart = 0; + this.lastAddr = null; + this.lastProjectId = null; + this.lastProjectNumber = null; + } + /** * Checks to see if the URL has changed since the last time we updated it * @@ -238,10 +245,10 @@ export class FCMService { * * @param serverUrl The new server URL */ - async setServerUrl(): Promise { + async setServerUrl(force = false): Promise { // Make sure we should be setting the URL const serverUrl = this.shouldUpdateUrl(); - if (!serverUrl) return; + if (!serverUrl && !force) return; // Make sure that if we haven't initialized, we do so if (!this.hasInitialized || !(await this.start())) return; @@ -249,20 +256,31 @@ export class FCMService { Server().log(`Updating Server Address in ${this.dbType} Database...`); // Update the URL - // If we fail, retry 3 times - await resultRetryer({ - maxTries: 3, + // If we fail, retry 12 times (for 1 minute) + let error: any = null; + const result = await resultRetryer({ + maxTries: 12, delayMs: 5000, getData: async () => { try { + Server().log(`Attempting to write server URL to database...`, 'debug'); await this.saveUrlToDb(serverUrl); + error = null; return true; - } catch { + } catch (ex: any) { + error = ex; return false; } } }); + if (!result) { + Server().log(`Failed to update Server Address in ${this.dbType} Database after 3 attempts!`, "error"); + if (error) Server().log(`DB Update Error: ${error?.message}`, "debug"); + } else { + Server().log('Successfully updated server address'); + } + this.lastAddr = serverUrl; this.lastProjectId = this.serverConfig?.project_id; this.lastProjectNumber = this.clientConfig?.project_info?.project_number; @@ -277,14 +295,12 @@ export class FCMService { } async setServerUrlFirestore(serverUrl: string): Promise { - const db = admin.app(AppName).firestore(); - - // Set the server URL and cache value + const db = FCMService.getApp().firestore(); await db.collection("server").doc('config').set({ serverUrl }); } async setServerUrlRealtime(serverUrl: string): Promise { - const db = admin.app(AppName).database(); + const db = FCMService.getApp().database(); // Update the config const config = db.ref("config"); @@ -337,21 +353,81 @@ export class FCMService { } } - private listenFirestoreDb() { + private async listenFirestoreDb() { const app = FCMService.getApp(); const db = app.firestore(); - db.collection("server") - .doc("config") - .onSnapshot((snapshot: admin.firestore.DocumentSnapshot)=> this.nextRestartHandler( - snapshot.data()?.nextRestart)); + + const startListening = () => new Promise((resolve, reject) => { + db.collection("server") + .doc("config") + .onSnapshot( + (snapshot: admin.firestore.DocumentSnapshot) => { + this.nextRestartHandler(snapshot.data()?.nextRestart); + resolve(); + }, async (error: any) => { + reject(error); + } + ); + }); + + // Try for 12 times (1 minute) + let success = false; + for (let i = 0; i < 12; i++) { + try { + await startListening(); + success = true; + break; + } catch (ex: any) { + Server().log(`An error occurred when listening for DB changes! Retrying in 5 seconds...`, "debug"); + Server().log(ex?.message ?? String(ex)); + await waitMs(5000); + } + } + + if (!success) { + Server().log(`Failed to listen for DB changes after 12 attempts!`, "error"); + } else { + Server().log('Successfully listening for DB changes'); + } } - private listenRealtimeDb() { + private async listenRealtimeDb() { const app = FCMService.getApp(); const db = app.database(); - db.ref("config") - .child("nextRestart") - .on("value", (snapshot: admin.database.DataSnapshot) => this.nextRestartHandler(snapshot.val())); + + const startListening = () => new Promise((resolve, reject) => { + db.ref("config") + .child("nextRestart") + .on( + "value", + async (snapshot: admin.database.DataSnapshot) => { + this.nextRestartHandler(snapshot.val()); + resolve(); + }, (error: any) => { + reject(error); + } + ); + }); + + // Try for 12 times (1 minute) + let success = false; + for (let i = 0; i < 12; i++) { + try { + await startListening(); + success = true; + break; + } catch (ex: any) { + Server().log(`An error occurred when listening for DB changes! Retrying in 5 seconds...`, "debug"); + Server().log(ex?.message ?? String(ex)); + await waitMs(5000); + } + } + + if (!success) { + Server().log(`Failed to listen for DB changes after 12 attempts!`, "error"); + } else { + Server().log('Successfully listening for DB changes'); + } } private async nextRestartHandler(value: any) { diff --git a/packages/server/src/server/services/findMyService/index.ts b/packages/server/src/server/services/findMyService/index.ts index 630b1eda..90a65292 100644 --- a/packages/server/src/server/services/findMyService/index.ts +++ b/packages/server/src/server/services/findMyService/index.ts @@ -6,7 +6,7 @@ import { startFindMyFriends, showFindMyFriends, quitFindMyFriends -} from "@server/api/v1/apple/scripts"; +} from "@server/api/apple/scripts"; import { waitMs } from "@server/helpers/utils"; import { Server } from "@server"; import { FindMyDevice, FindMyItem } from "@server/services/findMyService/types"; @@ -54,9 +54,8 @@ export class FindMyService { return FindMyService.readCacheFile(guid); } - static async refreshFriends(): Promise | null> { + static async refreshFriends(): Promise { await FindMyService.refresh(); - return await FindMyService.getFriends(); } private static readDataFile( @@ -111,7 +110,7 @@ export class FindMyService { // Make sure the Find My app is open. // Give it 3 seconds to open await FileSystem.executeAppleScript(startFindMyFriends()); - await waitMs(3000); + await waitMs(5000); // Bring the Find My app to the foreground so it refreshes the devices // Give it 5 seconods to refresh diff --git a/packages/server/src/server/services/httpService/api/v1/middleware/timeoutMiddleware.ts b/packages/server/src/server/services/httpService/api/v1/middleware/timeoutMiddleware.ts deleted file mode 100644 index 59159c92..00000000 --- a/packages/server/src/server/services/httpService/api/v1/middleware/timeoutMiddleware.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Server } from "@server"; -import { Context, Next } from "koa"; - -export const TimeoutMiddleware = async (ctx: Context, next: Next) => { - // Set timeout depending on the requested URL (default: 5 minutes) - let timeout = 5 * 60 * 1000; - - // If the request is to send a message attachment, increase the timeout to 30 minutes - if (ctx.request.path === "/api/v1/message/attachment") { - timeout = 30 * 60 * 1000; - } - - ctx.req.setTimeout(timeout, () => { - Server().log("HTTP request timed out!", "debug"); - }); - - await next(); -}; diff --git a/packages/server/src/server/services/index.ts b/packages/server/src/server/services/index.ts index 65bb0a5b..808864fb 100644 --- a/packages/server/src/server/services/index.ts +++ b/packages/server/src/server/services/index.ts @@ -1,4 +1,3 @@ -import { HttpService } from "./httpService"; import { FCMService } from "./fcmService"; import { CaffeinateService } from "./caffeinateService"; import { UpdateService } from "./updateService"; @@ -11,12 +10,10 @@ import { IPCService } from "./ipcService"; import { CertificateService } from "./certificateService"; import { WebhookService } from "./webhookService"; import { FindMyService } from "./findMyService"; -import { FacetimeService } from "./facetimeService"; import { ScheduledMessagesService } from "./scheduledMessagesService"; import { OauthService } from "./oauthService"; export { - HttpService, FCMService, CaffeinateService, UpdateService, @@ -28,7 +25,6 @@ export { CertificateService, CloudflareService, WebhookService, - FacetimeService, FindMyService, ScheduledMessagesService, OauthService diff --git a/packages/server/src/server/services/ipcService/index.ts b/packages/server/src/server/services/ipcService/index.ts index ac6ef4ca..7a951a0a 100644 --- a/packages/server/src/server/services/ipcService/index.ts +++ b/packages/server/src/server/services/ipcService/index.ts @@ -4,15 +4,15 @@ import process from "process"; import { Server } from "@server"; import { FileSystem } from "@server/fileSystem"; -import { AlertsInterface } from "@server/api/v1/interfaces/alertsInterface"; -import { openLogs, openAppData } from "@server/api/v1/apple/scripts"; +import { AlertsInterface } from "@server/api/interfaces/alertsInterface"; +import { openLogs, openAppData } from "@server/api/apple/scripts"; import { fixServerUrl } from "@server/helpers/utils"; -import { ContactInterface } from "@server/api/v1/interfaces/contactInterface"; -import { PrivateApiService } from "../privateApi/PrivateApiService"; +import { ContactInterface } from "@server/api/interfaces/contactInterface"; +import { PrivateApiService } from "../../api/privateApi/PrivateApiService"; import { getContactPermissionStatus, requestContactPermission } from "@server/utils/PermissionUtils"; -import { ScheduledMessagesInterface } from "@server/api/v1/interfaces/scheduledMessagesInterface"; -import { ChatInterface } from "@server/api/v1/interfaces/chatInterface"; -import { GeneralInterface } from "@server/api/v1/interfaces/generalInterface"; +import { ScheduledMessagesInterface } from "@server/api/interfaces/scheduledMessagesInterface"; +import { ChatInterface } from "@server/api/interfaces/chatInterface"; +import { GeneralInterface } from "@server/api/interfaces/generalInterface"; export class IPCService { /** @@ -141,8 +141,8 @@ export class IPCService { return await getContactPermissionStatus(); }); - ipcMain.handle("request-contact-permission", async (event, _) => { - return await requestContactPermission(); + ipcMain.handle("request-contact-permission", async (event, force) => { + return await requestContactPermission(force); }); ipcMain.handle("get-contacts", async (event, extraProperties) => { @@ -270,10 +270,6 @@ export class IPCService { return currentTop; }); - ipcMain.handle("refresh-api-contacts", async (_, __) => { - ContactInterface.refreshApiContacts(); - }); - ipcMain.handle("check-permissions", async (_, __) => { return await Server().checkPermissions(); }); diff --git a/packages/server/src/server/services/oauthService/index.ts b/packages/server/src/server/services/oauthService/index.ts index 8e4c0576..434c0a36 100644 --- a/packages/server/src/server/services/oauthService/index.ts +++ b/packages/server/src/server/services/oauthService/index.ts @@ -8,11 +8,12 @@ import { Server } from "@server"; import { FileSystem } from "@server/fileSystem"; import * as admin from "firebase-admin"; import { OAuth2Client } from "google-auth-library"; -import { google } from "googleapis"; +import { Auth, google } from "googleapis"; import { generateRandomString } from "@server/utils/CryptoUtils"; -import { isNotEmpty, resultAwaiter, waitMs } from "@server/helpers/utils"; +import { getObjectAsString, isEmpty, isNotEmpty, waitMs } from "@server/helpers/utils"; import { ProgressStatus } from "@server/types"; import { FCMService } from "../fcmService"; +import { BrowserWindow, HandlerDetails } from "electron"; /** * This service class hhandles the initial oauth workflows @@ -42,6 +43,14 @@ export class OauthService { completed = false; + private scopes = [ + "https://www.googleapis.com/auth/cloudplatformprojects", + "https://www.googleapis.com/auth/service.management", + "https://www.googleapis.com/auth/firebase", + "https://www.googleapis.com/auth/datastore", + "https://www.googleapis.com/auth/iam" + ]; + get callbackUrl(): string { return `http://localhost:${this.port}/oauth/callback`; } @@ -94,26 +103,28 @@ export class OauthService { try { Server().emitToUI("oauth-status", ProgressStatus.IN_PROGRESS); - Server().log(`[GCP] Creating project, "${this.projectName}"...`); + Server().log(`[GCP] Creating Google Cloud project, "${this.projectName}"...`); const project = await this.createGoogleCloudProject(); const projectId = project.projectId; - const projectNumber = project.projectNumber; - Server().log(`[GCP] Enabling Firestore...`); - await this.enableFirestoreApi(projectNumber); + // Enable the required APIs + await this.enableCloudApis(projectId); + await this.enableCloudResourceManager(projectId); + await this.enableFirebaseManagementApi(projectId); + await this.enableFirestoreApi(projectId); - Server().log(`[GCP] Adding Firebase...`); + Server().log(`[GCP] Adding Firebase to Google Cloud Project`) await this.addFirebase(projectId); + Server().log(`[GCP] Waiting for Service Account to generate (this may take some time)...`); + const serviceAccountJson = await this.getServiceAccount(projectId); + Server().log(`[GCP] Creating Firestore...`); await this.createDatabase(projectId); Server().log(`[GCP] Creating Android Configuration...`); await this.createAndroidApp(projectId); - Server().log(`[GCP] Generating Service Account JSON (this may take some time)...`); - const serviceAccountJson = await this.getServiceAccount(projectId); - Server().log(`[GCP] Generating Google Services JSON (this may take some time)...`); const servicesJson = await this.getGoogleServicesJson(projectId); @@ -132,6 +143,10 @@ export class OauthService { // Do nothing } + // Wait 10 seconds to ensure credentials propogate + Server().log(`[GCP] Ensuring credentials are propogated..`); + await waitMs(10000); + // Mark the service as completed Server().log(( `[GCP] Successfully created and configured your Google Project! ` + @@ -146,7 +161,12 @@ export class OauthService { // Start the FCM service. // Don't await because we don't want to catch the error here. FCMService.stop().then(async () => { + // Clear our markers & start the service + Server().fcm.clearLastValues(); await Server().fcm.start(); + }).catch(async (err) => { + Server().log('An issue occurred when stopping the FCM service after OAuth completion!', 'debug'); + Server().log(err?.message ?? String(err), 'debug'); }); } catch (ex: any) { Server().log(`[GCP] Failed to create project: ${ex?.message}`, "error"); @@ -165,16 +185,8 @@ export class OauthService { * @returns The OAuth URL */ async getOauthUrl() { - const scopes = [ - "https://www.googleapis.com/auth/cloudplatformprojects", - "https://www.googleapis.com/auth/service.management", - "https://www.googleapis.com/auth/firebase", - "https://www.googleapis.com/auth/datastore", - "https://www.googleapis.com/auth/iam" - ]; - const url = await this.oauthClient.generateAuthUrl({ - scope: scopes, + scope: this.scopes, response_type: 'token' }); @@ -194,13 +206,7 @@ export class OauthService { }); } - /** - * Creates the Google Cloud Project - * If the project already exists & is active, it returns the existing one - * - * @returns The project object - */ - async createGoogleCloudProject() { + async checkIfProjectExists() { // eslint-disable-next-line max-len const getUrl = `https://cloudresourcemanager.googleapis.com/v1/projects?filter=name%3A${this.projectName}%20AND%20lifecycleState%3AACTIVE`; const getRes = await this.sendRequest('GET', getUrl); @@ -212,26 +218,107 @@ export class OauthService { return getRes.data.projects[0]; } - // Create the new project - const postUrl = `https://cloudresourcemanager.googleapis.com/v1/projects`; - const projectId = `${this.projectName.toLowerCase()}-${generateRandomString(4)}`; - const data = { name: this.projectName, projectId }; - await this.sendRequest('POST', postUrl, data); - const projectData = await this.waitForData('GET', getUrl, null, 'projects'); - return projectData.projects.find((p: any) => p.projectId === projectId); + return null; } /** - * Enables Firestore in the Google Cloud Project + * Creates the Google Cloud Project + * If the project already exists & is active, it returns the existing one * - * @param projectNumber The project number - * @returns The response data - */ - async enableFirestoreApi(projectNumber: string) { + * @returns The project object + */ + async createGoogleCloudProject() { + let projectId: string = null; + + // First check if the project exists + const projectExists = await this.checkIfProjectExists(); + if (projectExists) return projectExists; + + // Helper function to create a new project + const createProj = async () => { + const postUrl = `https://cloudresourcemanager.googleapis.com/v1/projects`; + projectId = `${this.projectName.toLowerCase()}-${generateRandomString(4)}`; + const data = { name: this.projectName, projectId }; + Server().log(`[GCP] Creating project with name, "${this.projectName}", under project ID, "${projectId}"`); + const createRes = await this.sendRequest('POST', postUrl, data); + + // Wait for the operation to complete (try for 2 minutes) + const operationName = createRes.data.name; + const operationUrl = `https://cloudresourcemanager.googleapis.com/v1/${operationName}`; + return await this.waitForData('GET', operationUrl, null, 'done', 60, 5000); + }; + + let operationData = await createProj(); + + // If there is an error, throw it + if (isNotEmpty(operationData?.error)) { + const isTosError = operationData.error.message.includes('Terms of Service'); + if (isTosError) { + Server().log( + `[GCP] You must accept the Google Cloud Terms of Service before continuing! ` + + `A window will open in 10 seconds for you to accept the TOS. ` + + `Once you accept the TOS, you can close the window and setup will continue.` + ); + + await waitMs(10000); + await this.openWindow('https://console.cloud.google.com/projectcreate'); + } else { + throw new Error(`Error: ${getObjectAsString(operationData.error)}`); + } + + // Try again + Server().log(`[GCP] Retrying project creation...`); + operationData = await createProj(); + } + + // Throw an error if a project ID isn't returned + if (isEmpty(operationData?.response?.projectId)) { + throw new Error(`No Project ID was returned!`); + } + + // Fetch the project data // eslint-disable-next-line max-len - const projectUrl = `https://serviceusage.googleapis.com/v1/projects/${projectNumber}/services/firestore.googleapis.com:enable`; - const postRes = await this.sendRequest('POST', projectUrl); - return postRes.data; + const getUrl = `https://cloudresourcemanager.googleapis.com/v1/projects?filter=name%3A${this.projectName}%20AND%20lifecycleState%3AACTIVE`; + const projectData = await this.sendRequest('GET', getUrl); + return projectData.data.projects.find((p: any) => p.projectId === projectId); + } + + async enableCloudApis(projectId: string) { + Server().log(`[GCP] Enabling Cloud APIs`); + await this.enableService(projectId, 'cloudapis.googleapis.com'); + } + + async enableFirebaseManagementApi(projectId: string) { + Server().log(`[GCP] Enabling Firebase Management APIs`); + await this.enableService(projectId, 'firebase.googleapis.com'); + } + + async enableFirestoreApi(projectId: string) { + Server().log(`[GCP] Enabling Firestore APIs`); + await this.enableService(projectId, 'firestore.googleapis.com'); + } + + async enableCloudResourceManager(projectId: string) { + Server().log(`[GCP] Enabling Cloud Resource Manager APIs`); + await this.enableService(projectId, 'cloudresourcemanager.googleapis.com'); + } + + async enableIdentityApi(projectId: string) { + Server().log(`[GCP] Enabling IAM APIs`); + await this.enableService(projectId, 'iam.googleapis.com'); + } + + async enableService(projectId: string, service: string) { + const postUrl = `https://serviceusage.googleapis.com/v1/projects/${projectId}/services/${service}:enable`; + const createRes = await this.sendRequest('POST', postUrl, {}); + + // If the operation is already done, return + const operationName = createRes.data.name; + if (operationName.endsWith('DONE_OPERATION')) return; + + // Wait for the operation to complete + const operationUrl = `https://serviceusage.googleapis.com/v1/${operationName}`; + return await this.waitForData('GET', operationUrl, null, 'done', 30, 5000); } /** @@ -243,18 +330,37 @@ export class OauthService { async addFirebase(projectId: string) { try { const url = `https://firebase.googleapis.com/v1beta1/projects/${projectId}:addFirebase`; - const res = await this.sendRequest('POST', url); + const res = await this.sendRequest('POST', url, {}); await this.waitForData('GET', `https://firebase.googleapis.com/v1beta1/${res.data.name}`, null, 'name'); - await waitMs(5000); // Wait an addition 5 seconds to ensure Firebase is ready + await waitMs(5000); // Wait 5 seconds to ensure Firebase is ready } catch (ex: any) { if (ex.response?.data?.error?.code === 409) { Server().log(`[GCP] Firebase already exists!`); + } else if (ex.response?.data?.error?.code === 403) { + await this.addFirebaseManual(); } else { - throw ex; + Server().log( + `Failed to add Firebase to project: Data: ${getObjectAsString(ex.response?.data)}`, 'debug'); + throw new Error( + `Failed to add Firebase to project: ${ + getObjectAsString(ex.response?.data?.error?.message ?? ex.message)}}`); } } } + async addFirebaseManual() { + Server().log( + `[GCP] You must manually create the Firebase project! In 10 seconds, ` + + `a window will open where you can create a new project. ` + + `Select your existing BlueBubbles Resource and add Firebase to the project. ` + + `Once the project is created, you can close the window and setup will continue.` + ); + + await waitMs(10000); + await this.openWindow(`https://console.firebase.google.com/`); + Server().log(`[GCP] Resuming setup...`); + } + /** * Creates the default database for the Google Cloud Project * @@ -290,9 +396,16 @@ export class OauthService { async createAndroidApp(projectId: string) { try { const url = `https://firebase.googleapis.com/v1beta1/projects/${projectId}/androidApps`; - const data = { packageName: this.packageName }; - await this.tryUntilNoError('POST', url, data); - await this.waitForData('GET', `https://firebase.googleapis.com/v1beta1/projects/${projectId}/androidApps`); + const data = { displayName: this.projectName, packageName: this.packageName }; + const createRes = await this.sendRequest('POST', url, data); + + // Wait for the app to be created + const operationName = createRes.data.name; + const operationUrl = `https://firebase.googleapis.com/v1beta1/${operationName}`; + const operationResult = await this.waitForData('GET', operationUrl, null, 'done', 60, 5000); + if (operationResult.error) { + throw new Error(`Failed to create Android App: ${getObjectAsString(operationResult.error)}`); + } } catch (ex: any) { if (ex.response?.data?.error?.code === 409) { Server().log(`[GCP] Android Configuration already exists!`); @@ -302,6 +415,26 @@ export class OauthService { } } + /** + * Creates a Service Account for a given Google Cloud Project. + * + * @param projectId The project ID + * @param serviceAccountName The name of the service account + * @returns The service account data + */ + async createServiceAccount(projectId: string, serviceAccountName: string) { + const url = `https://iam.googleapis.com/v1/projects/${projectId}/serviceAccounts`; + const data = { + accountId: serviceAccountName, + serviceAccount: { + displayName: serviceAccountName, + description: "Firebase Admin SDK Service Agent" + } + }; + const res = await this.sendRequest('POST', url, data); + return res.data; + } + /** * Gets the Service Account ID from the Google Cloud Project * @@ -310,11 +443,17 @@ export class OauthService { */ async getFirebaseServiceAccountId(projectId: string): Promise { const url = `https://iam.googleapis.com/v1/projects/${projectId}/serviceAccounts`; - const response = await this.waitForData('GET', url, null, 'accounts', 60); // Wait up to 2 minutes - const firebaseServiceAccounts = response.accounts; - const firebaseServiceAccountId = firebaseServiceAccounts - .find((element: any) => element.displayName === "firebase-adminsdk").uniqueId; - return firebaseServiceAccountId; + + try { + // Max wait time: 5 minutes (60 attempts with 5 second delay) + const response = await this.waitForData('GET', url, null, 'accounts', 60, 5000); + const firebaseServiceAccounts = response.accounts; + const firebaseServiceAccountId = firebaseServiceAccounts + .find((element: any) => element.displayName === "firebase-adminsdk")?.uniqueId; + return firebaseServiceAccountId; + } catch { + return null; + } } /** @@ -325,6 +464,9 @@ export class OauthService { */ async getServiceAccount(projectId: string) { const accountId: string = await this.getFirebaseServiceAccountId(projectId); + if (!accountId) { + throw new Error('Failed to get Firebase Service Account! Please ensure that the project was created!'); + } // eslint-disable-next-line max-len const url = `https://iam.googleapis.com/v1/projects/${projectId}/serviceAccounts/${accountId}/keys`; @@ -386,10 +528,10 @@ export class OauthService { url: string, data: Record = null, key: string = null, - maxAttempts = 30 + maxAttempts = 30, + waitTime = 2000 ) { let attempts = 0; - const waitTime = 2000; // eslint-disable-next-line no-constant-condition while (true) { @@ -398,7 +540,12 @@ export class OauthService { if (key && isNotEmpty(res.data[key])) return res.data; attempts += 1; - if (attempts > maxAttempts) throw new Error(`Failed to get data from: ${url}`); + if (attempts > maxAttempts) { + Server().log(`Received data from failed request: ${getObjectAsString(res.data)}`, 'debug'); + throw new Error( + `Failed to get data from: ${url}. Please gather server logs and contact the developers!`); + } + await waitMs(waitTime); } } @@ -489,6 +636,34 @@ export class OauthService { }); } + private async openWindow(url: string, waitForClose = true) { + return new Promise((resolve, _) => { + const window = new BrowserWindow({ + width: 800, + height: 600, + webPreferences: { + nodeIntegration: true, + } + }); + + window.loadURL(url); + + // Open links in the same window + window.webContents.setWindowOpenHandler((details: HandlerDetails) => { + window.loadURL(details.url); + return { action: "deny" }; + }); + + if (waitForClose) { + window.on('closed', () => { + resolve(); + }); + } else { + resolve(); + } + }); + } + /** * Closes the HTTP server */ diff --git a/packages/server/src/server/services/privateApi/eventHandlers/index.ts b/packages/server/src/server/services/privateApi/eventHandlers/index.ts deleted file mode 100644 index dc8205aa..00000000 --- a/packages/server/src/server/services/privateApi/eventHandlers/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface PrivateApiEventHandler { - - types: string[]; - - handle(event: any): Promise; -} \ No newline at end of file diff --git a/packages/server/src/server/services/queueService/index.ts b/packages/server/src/server/services/queueService/index.ts index 20661902..15558cf3 100644 --- a/packages/server/src/server/services/queueService/index.ts +++ b/packages/server/src/server/services/queueService/index.ts @@ -1,6 +1,6 @@ -import { MessageInterface } from "@server/api/v1/interfaces/messageInterface"; +import { MessageInterface } from "@server/api/interfaces/messageInterface"; import { FileSystem } from "@server/fileSystem"; -import { ActionHandler } from "@server/api/v1/apple/actions"; +import { ActionHandler } from "@server/api/apple/actions"; import { isNotEmpty } from "@server/helpers/utils"; import { Server } from "@server"; diff --git a/packages/server/src/server/services/scheduledMessagesService/index.ts b/packages/server/src/server/services/scheduledMessagesService/index.ts index af98636f..98a340ba 100644 --- a/packages/server/src/server/services/scheduledMessagesService/index.ts +++ b/packages/server/src/server/services/scheduledMessagesService/index.ts @@ -1,7 +1,7 @@ import { Server } from "@server"; import { ScheduledMessage } from "@server/databases/server/entity"; -import { MessageInterface } from "@server/api/v1/interfaces/messageInterface"; -import { SendMessageParams } from "@server/api/v1/types"; +import { MessageInterface } from "@server/api/interfaces/messageInterface"; +import { SendMessageParams } from "@server/api/types"; import { FindOneOptions } from "typeorm"; import { SCHEDULED_MESSAGE_CREATED, diff --git a/packages/server/src/server/utils/ConfigUtils.ts b/packages/server/src/server/utils/ConfigUtils.ts new file mode 100644 index 00000000..ba768f5d --- /dev/null +++ b/packages/server/src/server/utils/ConfigUtils.ts @@ -0,0 +1,16 @@ +import { Server } from "@server"; + + +export const getStartDelay = (): number => { + const startDelayVal: any= (Server().repo.getConfig('start_delay') ?? '0'); + let startDelay = 0; + if (typeof startDelayVal === 'boolean' && startDelayVal === true) { + startDelay = 1; + } else if (typeof startDelayVal === 'boolean' && startDelayVal === false) { + startDelay = 0; + } else { + startDelay = Number.parseInt(startDelayVal); + } + + return startDelay; +} \ No newline at end of file diff --git a/packages/server/src/server/utils/PermissionUtils.ts b/packages/server/src/server/utils/PermissionUtils.ts index 7360bd42..5a096b42 100644 --- a/packages/server/src/server/utils/PermissionUtils.ts +++ b/packages/server/src/server/utils/PermissionUtils.ts @@ -1,6 +1,5 @@ import { Server } from "@server"; - -const contacts = require("node-mac-contacts"); +import { ContactsLib } from "@server/api/lib/ContactsLib"; export const getContactPermissionStatus = (): string => { @@ -8,7 +7,7 @@ export const getContactPermissionStatus = (): string => { let contactStatus = "Unknown"; try { - contactStatus = contacts.getAuthStatus(); + contactStatus = ContactsLib.getAuthStatus(); } catch (ex) { Server().log(`Failed to get contact permission status! Error: ${ex}`, "debug"); } @@ -16,15 +15,15 @@ export const getContactPermissionStatus = (): string => { return contactStatus; }; -export const requestContactPermission = async (): Promise => { +export const requestContactPermission = async (force = false): Promise => { // Check for contact permissions let contactStatus = getContactPermissionStatus(); try { // Only request access if the permission is "Not Determined" or "Unknown". // If the permission is denied, then it was explicitly denied and we shouldn't request it. - if (contactStatus === 'Not Determined' || contactStatus === 'Unknown') { - contactStatus = await contacts.requestAccess(); + if (force || contactStatus === 'Not Determined' || contactStatus === 'Unknown') { + contactStatus = await ContactsLib.requestAccess(); // Check the new status contactStatus = getContactPermissionStatus(); diff --git a/packages/server/src/trays/AppTray.ts b/packages/server/src/trays/AppTray.ts new file mode 100644 index 00000000..84765b22 --- /dev/null +++ b/packages/server/src/trays/AppTray.ts @@ -0,0 +1,164 @@ +import { autoUpdater } from "electron-updater"; +import { Server } from "@server"; +import { FileSystem } from "@server/fileSystem"; +import { Tray } from "."; +import { SERVER_UPDATE_DOWNLOADING } from "@server/events"; +import { Menu, nativeTheme, Tray as ElectronTray, app } from "electron"; +import { AppWindow } from "@windows/AppWindow"; + +export class AppTray extends Tray { + private static self: AppTray; + + private exitHandler: () => Promise; + + private arguments: Record = {}; + + private constructor() { + super(); + } + + public static getInstance(): AppTray { + if (!AppTray.self) { + AppTray.self = new AppTray(); + } + + return AppTray.self; + } + + setArguments(args: Record): AppTray { + this.arguments = args; + return this; + } + + setExitHandler(handler: () => Promise): AppTray { + this.exitHandler = handler; + return this; + } + + async build(): Promise { + let iconPath = path.join(FileSystem.resources, "macos", "icons", "tray-icon-dark.png"); + if (!nativeTheme.shouldUseDarkColors) + iconPath = path.join(FileSystem.resources, "macos", "icons", "tray-icon-light.png"); + + // If the this.instance is already created, just change the icon color + if (this.instance) { + this.instance.setImage(iconPath); + return; + } + + try { + this.instance = new ElectronTray(iconPath); + this.instance.setToolTip("BlueBubbles"); + this.instance.setContextMenu(this.buildMenu()); + + // Rebuild the this.instance each time it's clicked + this.instance.on("click", () => { + this.instance.setContextMenu(this.buildMenu()); + }); + } catch (ex: any) { + Server().log("Failed to load macOS this.instance entry!", "error"); + Server().log(ex?.message ?? String(ex), "debug"); + } + } + + buildMenu(): Menu { + const headless = (Server().repo?.getConfig("headless") as boolean) ?? false; + const noGpu = (Server().repo?.getConfig("disable_gpu") as boolean) ?? false; + let updateOpt: any = { + label: "Check for Updates", + type: "normal", + click: async () => { + if (Server()) { + await Server().updater.checkForUpdate({ showNoUpdateDialog: true }); + } + } + }; + + if (Server()?.updater?.hasUpdate ?? false) { + updateOpt = { + label: `Install Update (${Server().updater.updateInfo.updateInfo.version})`, + type: "normal", + click: async () => { + Server().emitMessage(SERVER_UPDATE_DOWNLOADING, null); + await autoUpdater.downloadUpdate(); + } + }; + } + + return Menu.buildFromTemplate([ + { + label: `BlueBubbles Server v${app.getVersion()}`, + enabled: false + }, + { + label: "Open", + type: "normal", + click: () => { + if (Server().window && !Server().window.isDestroyed) { + Server().window.show(); + } else { + AppWindow.getInstance().setArguments(this.arguments).build(); + } + } + }, + updateOpt, + { + // The checkmark will cover when this is enabled + label: `Headless Mode${headless ? " (Click to Disable)" : " (Click to Enable)"}`, + type: "checkbox", + checked: headless, + click: async () => { + const toggled = !headless; + await Server().repo.setConfig("headless", toggled); + if (!toggled) { + AppWindow.getInstance().setArguments(this.arguments).build(); + } else if (toggled && Server().window) { + Server().window.destroy(); + } + } + }, + { + // The checkmark will cover when this is enabled + label: noGpu ? "Enable GPU Rendering" : "Disable GPU Rendering", + click: async () => { + await Server().repo.setConfig("disable_gpu", !noGpu); + Server().relaunch(); + } + }, + { + label: "Restart", + type: "normal", + click: () => { + Server().relaunch(); + } + }, + { + type: "separator" + }, + { + label: `Server Address: ${Server().repo?.getConfig("server_address")}`, + enabled: false + }, + { + label: `Socket Connections: ${Server().httpService?.socketServer.sockets.sockets.size ?? 0}`, + enabled: false + }, + { + label: `Caffeinated: ${Server().caffeinate?.isCaffeinated}`, + enabled: false + }, + { + type: "separator" + }, + { + label: "Close", + type: "normal", + click: async () => { + if (this.exitHandler) { + await this.exitHandler(); + } + } + } + ]); + } +} diff --git a/packages/server/src/trays/index.ts b/packages/server/src/trays/index.ts new file mode 100644 index 00000000..d7362608 --- /dev/null +++ b/packages/server/src/trays/index.ts @@ -0,0 +1,7 @@ +import { Tray as ElectronTray } from "electron"; + +export abstract class Tray { + instance: ElectronTray; + + abstract build(): Promise; +} diff --git a/packages/server/src/windows/AppWindow.ts b/packages/server/src/windows/AppWindow.ts new file mode 100644 index 00000000..9d29dd7b --- /dev/null +++ b/packages/server/src/windows/AppWindow.ts @@ -0,0 +1,125 @@ +import { BrowserWindow, HandlerDetails, shell } from "electron"; +import { Window } from "."; +import { Server } from "@server"; +import { OAuthWindow } from "@windows/OAuthWindow"; + +export class AppWindow extends Window { + private arguments: Record = {}; + + private static self: AppWindow; + + private constructor() { + super(); + } + + public static getInstance(): AppWindow { + if (!AppWindow.self) { + AppWindow.self = new AppWindow(); + } + + return AppWindow.self; + } + + setArguments(args: Record): AppWindow { + this.arguments = args; + return this; + } + + build(): AppWindow { + const headless = (Server().repo?.getConfig("headless") as boolean) ?? false; + if (headless) { + Server().log("Headless mode enabled, skipping window creation..."); + return; + } + + this.instance = new BrowserWindow({ + title: "BlueBubbles Server", + useContentSize: true, + width: 1080, + minWidth: 850, + height: 750, + minHeight: 600, + webPreferences: { + nodeIntegration: true, // Required in new electron version + contextIsolation: false // Required or else we get a `global` is not defined error + } + }); + + if (process.env.NODE_ENV === "development") { + process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = "1"; // eslint-disable-line require-atomic-updates + this.instance.loadURL(`http://localhost:3000`); + } else { + this.instance.loadURL(`file://${path.join(__dirname, "index.html")}`); + } + + // Register the event handlers + this.registerEventHandlers(); + + // Set the new window in the Server() + Server(this.arguments, this.instance); + + return this; + } + + registerEventHandlers() { + this.instance.on("closed", () => { + this.instance = null; + }); + + // Prevent the title from being changed from BlueBubbles + this.instance.on("page-title-updated", evt => { + evt.preventDefault(); + }); + + // Make links open in the browser + this.instance.webContents.setWindowOpenHandler((details: HandlerDetails) => { + if (details.url.startsWith("https://accounts.google.com/o/oauth2/v2/auth")) { + OAuthWindow.getInstance(details.url).build(); + } else { + shell.openExternal(details.url); + } + + return { action: "deny" }; + }); + + // Hook onto when we load the UI + this.instance.webContents.on("dom-ready", async () => { + Server().uiLoaded = true; + + if (!this.instance.webContents.isDestroyed()) { + this.instance.webContents.send("config-update", Server().repo.config); + } + }); + + // Hook onto when the UI finishes loading + this.instance.webContents.on("did-finish-load", async () => { + Server().uiLoaded = true; + }); + + // Hook onto when the UI fails to load + this.instance.webContents.on( + "did-fail-load", + async (event, errorCode, errorDescription, validatedURL, frameProcessId, frameRoutingId) => { + Server().uiLoaded = false; + Server().log(`Failed to load UI! Error: [${errorCode}] ${errorDescription}`, "error"); + } + ); + + // Hook onto when the renderer process crashes + this.instance.webContents.on("render-process-gone", async (event, details) => { + Server().uiLoaded = false; + Server().log(`Renderer process crashed! Error: [${details.exitCode}] ${details.reason}`, "error"); + }); + + // Hook onto when the webcontents are destroyed + this.instance.webContents.on("destroyed", async () => { + Server().uiLoaded = false; + Server().log(`Webcontents were destroyed.`, "debug"); + }); + + // Hook onto when there is a preload error + this.instance.webContents.on("preload-error", async (event, preloadPath, error) => { + Server().log(`A preload error occurred: Error: ${error.message}.`, "error"); + }); + } +} diff --git a/packages/server/src/windows/OAuthWindow.ts b/packages/server/src/windows/OAuthWindow.ts new file mode 100644 index 00000000..8d3f3fef --- /dev/null +++ b/packages/server/src/windows/OAuthWindow.ts @@ -0,0 +1,55 @@ +import { BrowserWindow } from "electron"; +import { Window } from "."; +import { Server } from "@server"; + +export class OAuthWindow extends Window { + private url: string = null; + + private static self: OAuthWindow; + + private constructor(url: string) { + super(); + this.url = url; + } + + public static getInstance(url: string): OAuthWindow { + if (!OAuthWindow.self) { + OAuthWindow.self = new OAuthWindow(url); + } + + return OAuthWindow.self; + } + + build(): OAuthWindow { + // Create new Browser window + if (this.instance && !this.instance.isDestroyed) this.instance.destroy(); + this.instance = new BrowserWindow({ + width: 800, + height: 600, + webPreferences: { + nodeIntegration: true + } + }); + + this.instance.loadURL(this.url); + this.instance.webContents.on("did-finish-load", () => { + const url = this.instance.webContents.getURL(); + if (url.split("#")[0] !== Server().oauthService?.callbackUrl) return; + + // Extract the token from the URL + const hash = url.split("#")[1]; + const params = new URLSearchParams(hash); + const token = params.get("access_token"); + const expires = params.get("expires_in"); + Server().oauthService.authToken = token; + Server().oauthService.expiresIn = Number.parseInt(expires); + Server().oauthService.handleProjectCreation(); + + // Clear the window data + this.instance.close(); + this.instance = null; + }); + + return this; + } +} diff --git a/packages/server/src/windows/index.ts b/packages/server/src/windows/index.ts new file mode 100644 index 00000000..bef9eba0 --- /dev/null +++ b/packages/server/src/windows/index.ts @@ -0,0 +1,13 @@ +import { BrowserWindow } from "electron"; + +export class Window { + instance: BrowserWindow = null; + + get window(): BrowserWindow { + return this.instance; + } + + build(): Window { + throw new Error("Method not implemented."); + } +} diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 380ecc00..7116e6d6 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -5,6 +5,8 @@ "paths": { "@server": ["server/index"], "@server/*": ["server/*"], + "@windows/*": ["windows/*"], + "@trays/*": ["trays/*"], }, "outDir": "./dist", "strictNullChecks": false, diff --git a/packages/ui/package.json b/packages/ui/package.json index 41774f31..a5ea0e07 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@bluebubbles/ui", - "version": "1.8.0", + "version": "1.9.0", "homepage": "./", "license": "Apache-2.0", "scripts": { diff --git a/packages/ui/src/app/components/buttons/PaginationNextButton.tsx b/packages/ui/src/app/components/buttons/PaginationNextButton.tsx new file mode 100644 index 00000000..04fbee52 --- /dev/null +++ b/packages/ui/src/app/components/buttons/PaginationNextButton.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { PaginationNext } from '@ajna/pagination'; + +export const PaginationNextButton = (): JSX.Element => { + return Next; +}; diff --git a/packages/ui/src/app/components/buttons/PaginationPreviousButton.tsx b/packages/ui/src/app/components/buttons/PaginationPreviousButton.tsx new file mode 100644 index 00000000..ad12eb69 --- /dev/null +++ b/packages/ui/src/app/components/buttons/PaginationPreviousButton.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { PaginationPrevious } from '@ajna/pagination'; + +export const PaginationPreviousButton = (): JSX.Element => { + return Previous; +}; diff --git a/packages/ui/src/app/components/fields/FacetimeDetectionField.tsx b/packages/ui/src/app/components/fields/FacetimeDetectionField.tsx deleted file mode 100644 index c7254b13..00000000 --- a/packages/ui/src/app/components/fields/FacetimeDetectionField.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { - FormControl, - FormHelperText, - Checkbox, - Text -} from '@chakra-ui/react'; -import { useAppSelector } from '../../hooks'; -import { getCurrentPermissions } from 'app/utils/IpcUtils'; -import { onCheckboxToggle } from '../../actions/ConfigActions'; - - -export interface FacetimeDetectionFieldProps { - helpText?: string; -} - -export const FacetimeDetectionField = ({ helpText }: FacetimeDetectionFieldProps): JSX.Element => { - const facetimeDetection: boolean = (useAppSelector(state => state.config.facetime_detection) ?? false); - const [permissions, setPermissions] = useState({} as any); - const isDisabled = !(permissions?.accessibility ?? false); - - useEffect(() => { - getCurrentPermissions().then(permissions => { - setPermissions(permissions); - }); - }, []); - - return ( - - Incoming Facetime Detection - - {helpText ?? ( - <> - - Enabling this will allow BlueBubbles to detect incoming Facetime calls and notify clients. - - - This requires the Accessibility Permission. - - - )} - - - ); -}; \ No newline at end of file diff --git a/packages/ui/src/app/components/fields/StartDelayField.tsx b/packages/ui/src/app/components/fields/StartDelayField.tsx new file mode 100644 index 00000000..ebd7808b --- /dev/null +++ b/packages/ui/src/app/components/fields/StartDelayField.tsx @@ -0,0 +1,95 @@ +import React, { useEffect, useState } from 'react'; +import { + FormControl, + FormLabel, + FormHelperText, + Input, + IconButton, + FormErrorMessage, + Flex +} from '@chakra-ui/react'; +import { useAppDispatch, useAppSelector } from '../../hooks'; +import { showSuccessToast } from '../../utils/ToastUtils'; +import { setConfig } from '../../slices/ConfigSlice'; +import { AiOutlineSave } from 'react-icons/ai'; + + +export interface StartDelayFieldProps { + helpText?: string; +} + +export const StartDelayField = ({ helpText }: StartDelayFieldProps): JSX.Element => { + const dispatch = useAppDispatch(); + + const startDelayVal = useAppSelector(state => state.config.start_delay) ?? '0'; + let startDelay = 0; + if (typeof startDelayVal === 'boolean' && startDelayVal === true) { + startDelay = 1; + } else if (typeof startDelayVal === 'boolean' && startDelayVal === false) { + startDelay = 0; + } else { + startDelay = Number.parseInt(startDelayVal); + } + + const [newStartDelay, setStartDelay] = useState(startDelay); + const [startDelayError, setDelayError] = useState(''); + const hasDelayError: boolean = (startDelayError?? '').length > 0; + + useEffect(() => { setStartDelay(startDelay); }, [startDelay]); + + /** + * A handler & validator for saving a new startDelay. + * + * @param theNewDelay - The new startDelay to save + */ + const saveStartDelay = (theNewDelay: number): void => { + // Validate the startDelay + if (theNewDelay < 0) { + setDelayError('Start Delay must be greater than 0'); + return; + } else if (theNewDelay === startDelay) { + setDelayError('You have not changed the Start Delay since your last save!'); + return; + } + + dispatch(setConfig({ name: 'start_delay', value: String(theNewDelay) })); + if (hasDelayError) setDelayError(''); + showSuccessToast({ + id: 'settings', + duration: 4000, + description: 'Successfully saved new Start Delay!' + }); + }; + + return ( + + Start Delay (Seconds) + + { + if (hasDelayError) setDelayError(''); + setStartDelay(Number.parseInt(e.target.value)); + }} + /> + } + onClick={() => saveStartDelay(newStartDelay)} + /> + + {!hasDelayError ? ( + + {helpText ?? 'Enter the number of seconds to delay the server start by. This is useful on older hardware.'} + + ) : ( + {startDelayError} + )} + + ); +}; \ No newline at end of file diff --git a/packages/ui/src/app/constants.ts b/packages/ui/src/app/constants.ts index 86a6adae..2410d55f 100644 --- a/packages/ui/src/app/constants.ts +++ b/packages/ui/src/app/constants.ts @@ -66,6 +66,34 @@ export const webhookEventOptions = [ { label: 'Incoming Facetime Call', value: 'incoming-facetime' + }, + { + 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' } ]; diff --git a/packages/ui/src/app/containers/navigation/Navigation.tsx b/packages/ui/src/app/containers/navigation/Navigation.tsx index cfa35097..70f526d0 100644 --- a/packages/ui/src/app/containers/navigation/Navigation.tsx +++ b/packages/ui/src/app/containers/navigation/Navigation.tsx @@ -142,7 +142,7 @@ export const Navigation = (): JSX.Element => { closeNotification(onNotificationClose, dispatch)} isOpen={isNotificationsOpen} size="lg"> - + Notifications / Alerts ({unreadCount}) diff --git a/packages/ui/src/app/layouts/contacts/ContactsLayout.tsx b/packages/ui/src/app/layouts/contacts/ContactsLayout.tsx index 87856e45..cf10d137 100644 --- a/packages/ui/src/app/layouts/contacts/ContactsLayout.tsx +++ b/packages/ui/src/app/layouts/contacts/ContactsLayout.tsx @@ -28,9 +28,7 @@ import { import { Pagination, usePagination, - PaginationNext, PaginationPage, - PaginationPrevious, PaginationContainer, PaginationPageGroup, } from '@ajna/pagination'; @@ -44,6 +42,8 @@ import { FiTrash } from 'react-icons/fi'; import { ConfirmationItems, showSuccessToast } from 'app/utils/ToastUtils'; import { ConfirmationDialog } from 'app/components/modals/ConfirmationDialog'; import { waitMs } from 'app/utils/GenericUtils'; +import { PaginationPreviousButton } from 'app/components/buttons/PaginationPreviousButton'; +import { PaginationNextButton } from 'app/components/buttons/PaginationNextButton'; const perPage = 25; @@ -104,7 +104,7 @@ export const ContactsLayout = (): JSX.Element => { const requestContactPermission = async (): Promise => { setPermission(null); - ipcRenderer.invoke('request-contact-permission').then((status: string) => { + ipcRenderer.invoke('request-contact-permission', true).then((status: string) => { setPermission(status); }).catch(() => { setPermission('Unknown'); @@ -405,7 +405,7 @@ export const ContactsLayout = (): JSX.Element => { w="full" pt={2} > - Previous + {pages.map((page: number) => ( @@ -421,7 +421,7 @@ export const ContactsLayout = (): JSX.Element => { ))} - Next + diff --git a/packages/ui/src/app/layouts/notifications/NotificationsLayout.tsx b/packages/ui/src/app/layouts/notifications/NotificationsLayout.tsx index de2f813b..223b1b14 100644 --- a/packages/ui/src/app/layouts/notifications/NotificationsLayout.tsx +++ b/packages/ui/src/app/layouts/notifications/NotificationsLayout.tsx @@ -80,6 +80,7 @@ export const NotificationsLayout = (): JSX.Element => { if (success) { dispatch(setConfig({ name: 'fcm_client', 'value': null })); dispatch(setConfig({ name: 'fcm_server', 'value': null })); + setAuthStatus(ProgressStatus.NOT_STARTED); } } } diff --git a/packages/ui/src/app/layouts/scheduledMessages/ScheduledMessagesLayout.tsx b/packages/ui/src/app/layouts/scheduledMessages/ScheduledMessagesLayout.tsx index 14a48833..897df59e 100644 --- a/packages/ui/src/app/layouts/scheduledMessages/ScheduledMessagesLayout.tsx +++ b/packages/ui/src/app/layouts/scheduledMessages/ScheduledMessagesLayout.tsx @@ -25,9 +25,7 @@ import { import { Pagination, usePagination, - PaginationNext, PaginationPage, - PaginationPrevious, PaginationContainer, PaginationPageGroup, } from '@ajna/pagination'; @@ -41,6 +39,8 @@ import { ConfirmationDialog } from 'app/components/modals/ConfirmationDialog'; import { ScheduledMessageDialog } from 'app/components/modals/ScheduledMessageDialog'; import { ScheduledMessageItem, ScheduledMessagesTable } from 'app/components/tables/ScheduledMessagesTable'; import { createScheduledMessage, deleteScheduledMessage, deleteScheduledMessages } from 'app/utils/IpcUtils'; +import { PaginationPreviousButton } from 'app/components/buttons/PaginationPreviousButton'; +import { PaginationNextButton } from 'app/components/buttons/PaginationNextButton'; const perPage = 25; @@ -242,7 +242,7 @@ export const ScheduledMessagesLayout = (): JSX.Element => { w="full" pt={2} > - Previous + {pages.map((page: number) => ( @@ -256,7 +256,7 @@ export const ScheduledMessagesLayout = (): JSX.Element => { ))} - Next + diff --git a/packages/ui/src/app/layouts/settings/features/FeatureSettings.tsx b/packages/ui/src/app/layouts/settings/features/FeatureSettings.tsx index 99fdb47f..2c3289d1 100644 --- a/packages/ui/src/app/layouts/settings/features/FeatureSettings.tsx +++ b/packages/ui/src/app/layouts/settings/features/FeatureSettings.tsx @@ -16,8 +16,8 @@ import { AutoCaffeinateField } from '../../../components/fields/AutoCaffeinateFi import { DockBadgeField } from '../../../components/fields/DockBadgeField'; import { HideDockIconField } from '../../../components/fields/HideDockIconField'; import { StartViaTerminalField } from '../../../components/fields/StartViaTerminalField'; -import { FacetimeDetectionField } from '../../../components/fields/FacetimeDetectionField'; import { StartMinimizedField } from '../../../components/fields/StartMinimizedField'; +import { StartDelayField } from 'app/components/fields/StartDelayField'; export const FeatureSettings = (): JSX.Element => { @@ -27,8 +27,6 @@ export const FeatureSettings = (): JSX.Element => { Features - - @@ -39,6 +37,8 @@ export const FeatureSettings = (): JSX.Element => { + + diff --git a/packages/ui/src/app/layouts/walkthrough/configurations/ConfigurationsWalkthrough.tsx b/packages/ui/src/app/layouts/walkthrough/configurations/ConfigurationsWalkthrough.tsx index 05a38d04..e38273d5 100644 --- a/packages/ui/src/app/layouts/walkthrough/configurations/ConfigurationsWalkthrough.tsx +++ b/packages/ui/src/app/layouts/walkthrough/configurations/ConfigurationsWalkthrough.tsx @@ -9,7 +9,6 @@ import { AutoCaffeinateField } from '../../../components/fields/AutoCaffeinateFi import { CheckForUpdatesField } from '../../../components/fields/CheckForUpdatesField'; import { AutoInstallUpdatesField } from '../../../components/fields/AutoInstallUpdatesField'; import { UseOledDarkModeField } from '../../../components/fields/OledDarkThemeField'; -import { FacetimeDetectionField } from '../../../components/fields/FacetimeDetectionField'; export const ConfigurationsWalkthrough = (): JSX.Element => { @@ -23,8 +22,6 @@ export const ConfigurationsWalkthrough = (): JSX.Element => { Features - - diff --git a/packages/ui/src/app/layouts/walkthrough/notifications/NotificationsWalkthrough.tsx b/packages/ui/src/app/layouts/walkthrough/notifications/NotificationsWalkthrough.tsx index d7714b22..7cdabde4 100644 --- a/packages/ui/src/app/layouts/walkthrough/notifications/NotificationsWalkthrough.tsx +++ b/packages/ui/src/app/layouts/walkthrough/notifications/NotificationsWalkthrough.tsx @@ -52,7 +52,6 @@ export const NotificationsWalkthrough = (): JSX.Element => { const alertOpen = errors.length > 0; useEffect(() => { - console.log('HERE'); ipcRenderer.removeAllListeners('oauth-status'); getOauthUrl().then(url => setOauthUrl(url)); }, []); diff --git a/yarn.lock b/yarn.lock index 5c9fa646..d180cc2c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2190,16 +2190,18 @@ optionalDependencies: global-agent "^3.0.0" -"@electron/universal@1.0.5": - version "1.0.5" - resolved "https://registry.yarnpkg.com/@electron/universal/-/universal-1.0.5.tgz#b812340e4ef21da2b3ee77b2b4d35c9b86defe37" - integrity sha512-zX9O6+jr2NMyAdSkwEUlyltiI4/EBLu2Ls/VD3pUQdi3cAYeYfdQnT2AJJ38HE4QxLccbU13LSpccw1IWlkyag== +"@electron/universal@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@electron/universal/-/universal-1.2.1.tgz#3c2c4ff37063a4e9ab1e6ff57db0bc619bc82339" + integrity sha512-7323HyMh7KBAl/nPDppdLsC87G6RwRU02dy5FPeGB1eS7rUePh55+WNWiDPLhFQqqVPHzh77M69uhmoT8XnwMQ== dependencies: "@malept/cross-spawn-promise" "^1.1.0" - asar "^3.0.3" + asar "^3.1.0" debug "^4.3.1" dir-compare "^2.4.0" fs-extra "^9.0.1" + minimatch "^3.0.4" + plist "^3.0.4" "@emotion/babel-plugin@^11", "@emotion/babel-plugin@^11.10.6": version "11.10.6" @@ -4537,13 +4539,6 @@ ajv@^8.0.0, ajv@^8.6.0, ajv@^8.8.0: require-from-string "^2.0.2" uri-js "^4.2.2" -ansi-align@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.1.tgz#0cdf12e111ace773a86e9a1fad1225c43cb19a59" - integrity sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w== - dependencies: - string-width "^4.1.0" - ansi-colors@^4.1.1: version "4.1.3" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" @@ -4603,40 +4598,41 @@ anymatch@^3.0.0, anymatch@^3.0.3, anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" -app-builder-bin@3.7.1: - version "3.7.1" - resolved "https://registry.yarnpkg.com/app-builder-bin/-/app-builder-bin-3.7.1.tgz#cb0825c5e12efc85b196ac3ed9c89f076c61040e" - integrity sha512-ql93vEUq6WsstGXD+SBLSIQw6SNnhbDEM0swzgugytMxLp3rT24Ag/jcC80ZHxiPRTdew1niuR7P3/FCrDqIjw== +app-builder-bin@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/app-builder-bin/-/app-builder-bin-4.0.0.tgz#1df8e654bd1395e4a319d82545c98667d7eed2f0" + integrity sha512-xwdG0FJPQMe0M0UA4Tz0zEB8rBJTRA5a476ZawAqiBkMv16GRK5xpXThOjMaEOFnZ6zabejjG4J3da0SXG63KA== -app-builder-lib@22.14.13: - version "22.14.13" - resolved "https://registry.yarnpkg.com/app-builder-lib/-/app-builder-lib-22.14.13.tgz#c1f5b6afc86596357598bb90b69eef06c7c2eeb3" - integrity sha512-SufmrtxU+D0Tn948fjEwAOlCN9757UXLkzzTWXMwZKR/5hisvgqeeBepWfphMIE6OkDGz0fbzEhL1P2Pty4XMg== +app-builder-lib@23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/app-builder-lib/-/app-builder-lib-23.6.0.tgz#03cade02838c077db99d86212d61c5fc1d6da1a8" + integrity sha512-dQYDuqm/rmy8GSCE6Xl/3ShJg6Ab4bZJMT8KaTKGzT436gl1DN4REP3FCWfXoh75qGTJ+u+WsdnnpO9Jl8nyMA== dependencies: "7zip-bin" "~5.1.1" "@develar/schema-utils" "~2.6.5" - "@electron/universal" "1.0.5" + "@electron/universal" "1.2.1" "@malept/flatpak-bundler" "^0.4.0" async-exit-hook "^2.0.1" bluebird-lst "^1.0.9" - builder-util "22.14.13" - builder-util-runtime "8.9.2" + builder-util "23.6.0" + builder-util-runtime "9.1.1" chromium-pickle-js "^0.2.0" - debug "^4.3.2" - ejs "^3.1.6" - electron-osx-sign "^0.5.0" - electron-publish "22.14.13" + debug "^4.3.4" + ejs "^3.1.7" + electron-osx-sign "^0.6.0" + electron-publish "23.6.0" form-data "^4.0.0" - fs-extra "^10.0.0" - hosted-git-info "^4.0.2" + fs-extra "^10.1.0" + hosted-git-info "^4.1.0" is-ci "^3.0.0" - isbinaryfile "^4.0.8" + isbinaryfile "^4.0.10" js-yaml "^4.1.0" lazy-val "^1.0.5" - minimatch "^3.0.4" + minimatch "^3.1.2" read-config-file "6.2.0" sanitize-filename "^1.6.3" - semver "^7.3.5" + semver "^7.3.7" + tar "^6.1.11" temp-file "^3.4.0" app-root-path@^3.0.0: @@ -4801,7 +4797,7 @@ asap@~2.0.3, asap@~2.0.6: resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== -asar@^3.0.3: +asar@^3.1.0: version "3.2.0" resolved "https://registry.yarnpkg.com/asar/-/asar-3.2.0.tgz#e6edb5edd6f627ebef04db62f771c61bea9c1221" integrity sha512-COdw2ZQvKdFGFxXwX3oYh2/sOsJWJegrdJCGxnN4MZ7IULgRBp9P6665aqj9z1v9VwP4oP1hRBojRDQ//IGgAg== @@ -5177,20 +5173,6 @@ boolean@^3.0.1: resolved "https://registry.yarnpkg.com/boolean/-/boolean-3.2.0.tgz#9e5294af4e98314494cbb17979fa54ca159f116b" integrity sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw== -boxen@^5.0.0: - version "5.1.2" - resolved "https://registry.yarnpkg.com/boxen/-/boxen-5.1.2.tgz#788cb686fc83c1f486dfa8a40c68fc2b831d2b50" - integrity sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ== - dependencies: - ansi-align "^3.0.0" - camelcase "^6.2.0" - chalk "^4.1.0" - cli-boxes "^2.2.1" - string-width "^4.2.2" - type-fest "^0.20.2" - widest-line "^3.1.0" - wrap-ansi "^7.0.0" - bplist-parser@^0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/bplist-parser/-/bplist-parser-0.3.2.tgz#3ac79d67ec52c4c107893e0237eb787cbacbced7" @@ -5296,14 +5278,6 @@ buffer@^6.0.3: base64-js "^1.3.1" ieee754 "^1.2.1" -builder-util-runtime@8.9.2: - version "8.9.2" - resolved "https://registry.yarnpkg.com/builder-util-runtime/-/builder-util-runtime-8.9.2.tgz#a9669ae5b5dcabfe411ded26678e7ae997246c28" - integrity sha512-rhuKm5vh7E0aAmT6i8aoSfEjxzdYEFX7zDApK+eNgOhjofnWb74d9SRJv0H/8nsgOkos0TZ4zxW0P8J4N7xQ2A== - dependencies: - debug "^4.3.2" - sax "^1.2.4" - builder-util-runtime@9.1.1: version "9.1.1" resolved "https://registry.yarnpkg.com/builder-util-runtime/-/builder-util-runtime-9.1.1.tgz#2da7b34e78a64ad14ccd070d6eed4662d893bd60" @@ -5312,20 +5286,20 @@ builder-util-runtime@9.1.1: debug "^4.3.4" sax "^1.2.4" -builder-util@22.14.13: - version "22.14.13" - resolved "https://registry.yarnpkg.com/builder-util/-/builder-util-22.14.13.tgz#41b5b7b4ee53aff4e09cc007fb144522598f3ce6" - integrity sha512-oePC/qrrUuerhmH5iaCJzPRAKlSBylrhzuAJmRQClTyWnZUv6jbaHh+VoHMbEiE661wrj2S2aV7/bQh12cj1OA== +builder-util@23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/builder-util/-/builder-util-23.6.0.tgz#1880ec6da7da3fd6fa19b8bd71df7f39e8d17dd9" + integrity sha512-QiQHweYsh8o+U/KNCZFSvISRnvRctb8m/2rB2I1JdByzvNKxPeFLlHFRPQRXab6aYeXc18j9LpsDLJ3sGQmWTQ== dependencies: "7zip-bin" "~5.1.1" "@types/debug" "^4.1.6" "@types/fs-extra" "^9.0.11" - app-builder-bin "3.7.1" + app-builder-bin "4.0.0" bluebird-lst "^1.0.9" - builder-util-runtime "8.9.2" + builder-util-runtime "9.1.1" chalk "^4.1.1" cross-spawn "^7.0.3" - debug "^4.3.2" + debug "^4.3.4" fs-extra "^10.0.0" http-proxy-agent "^5.0.0" https-proxy-agent "^5.0.0" @@ -5588,11 +5562,6 @@ clean-stack@^2.0.0: resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== -cli-boxes@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.1.tgz#ddd5035d25094fce220e9cab40a45840a440318f" - integrity sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw== - cli-cursor@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" @@ -5884,18 +5853,6 @@ config-chain@^1.1.11: ini "^1.3.4" proto-list "~1.2.1" -configstore@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/configstore/-/configstore-5.0.1.tgz#d365021b5df4b98cdd187d6a3b0e3f6a7cc5ed96" - integrity sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA== - dependencies: - dot-prop "^5.2.0" - graceful-fs "^4.1.2" - make-dir "^3.0.0" - unique-string "^2.0.0" - write-file-atomic "^3.0.0" - xdg-basedir "^4.0.0" - confusing-browser-globals@^1.0.11: version "1.0.11" resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz#ae40e9b57cdd3915408a2805ebd3a5585608dc81" @@ -6524,21 +6481,21 @@ dlv@^1.1.3: resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79" integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA== -dmg-builder@22.14.13: - version "22.14.13" - resolved "https://registry.yarnpkg.com/dmg-builder/-/dmg-builder-22.14.13.tgz#cc613f3c18e889b8777d525991fd52f50a564f8c" - integrity sha512-xNOugB6AbIRETeU2uID15sUfjdZZcKdxK8xkFnwIggsM00PJ12JxpLNPTjcRoUnfwj3WrPjilrO64vRMwNItQg== +dmg-builder@23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/dmg-builder/-/dmg-builder-23.6.0.tgz#d39d3871bce996f16c07d2cafe922d6ecbb2a948" + integrity sha512-jFZvY1JohyHarIAlTbfQOk+HnceGjjAdFjVn3n8xlDWKsYNqbO4muca6qXEZTfGXeQMG7TYim6CeS5XKSfSsGA== dependencies: - app-builder-lib "22.14.13" - builder-util "22.14.13" - builder-util-runtime "8.9.2" + app-builder-lib "23.6.0" + builder-util "23.6.0" + builder-util-runtime "9.1.1" fs-extra "^10.0.0" iconv-lite "^0.6.2" js-yaml "^4.1.0" optionalDependencies: - dmg-license "^1.0.9" + dmg-license "^1.0.11" -dmg-license@^1.0.9: +dmg-license@^1.0.11: version "1.0.11" resolved "https://registry.yarnpkg.com/dmg-license/-/dmg-license-1.0.11.tgz#7b3bc3745d1b52be7506b4ee80cb61df6e4cd79a" integrity sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q== @@ -6659,13 +6616,6 @@ dot-case@^3.0.4: no-case "^3.0.4" tslib "^2.0.3" -dot-prop@^5.2.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88" - integrity sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q== - dependencies: - is-obj "^2.0.0" - dotenv-expand@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-5.1.0.tgz#3fbaf020bfd794884072ea26b1e9791d45a629f0" @@ -6718,30 +6668,30 @@ ee-first@1.1.1: resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== -ejs@^3.1.6: +ejs@^3.1.6, ejs@^3.1.7: version "3.1.9" resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.9.tgz#03c9e8777fe12686a9effcef22303ca3d8eeb361" integrity sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ== dependencies: jake "^10.8.5" -electron-builder@^22.9.1: - version "22.14.13" - resolved "https://registry.yarnpkg.com/electron-builder/-/electron-builder-22.14.13.tgz#fd40564685cf5422a8f8d667940af3d3776f4fb8" - integrity sha512-3fgLxqF2TXVKiUPeg74O4V3l0l3j7ERLazo8sUbRkApw0+4iVAf2BJkHsHMaXiigsgCoEzK/F4/rB5rne/VAnw== +electron-builder@^23.0.2: + version "23.6.0" + resolved "https://registry.yarnpkg.com/electron-builder/-/electron-builder-23.6.0.tgz#c79050cbdce90ed96c5feb67c34e9e0a21b5331b" + integrity sha512-y8D4zO+HXGCNxFBV/JlyhFnoQ0Y0K7/sFH+XwIbj47pqaW8S6PGYQbjoObolKBR1ddQFPt4rwp4CnwMJrW3HAw== dependencies: "@types/yargs" "^17.0.1" - app-builder-lib "22.14.13" - builder-util "22.14.13" - builder-util-runtime "8.9.2" + app-builder-lib "23.6.0" + builder-util "23.6.0" + builder-util-runtime "9.1.1" chalk "^4.1.1" - dmg-builder "22.14.13" + dmg-builder "23.6.0" fs-extra "^10.0.0" is-ci "^3.0.0" lazy-val "^1.0.5" read-config-file "6.2.0" - update-notifier "^5.1.0" - yargs "^17.0.1" + simple-update-notifier "^1.0.7" + yargs "^17.5.1" electron-log@^4.4.7: version "4.4.8" @@ -6756,10 +6706,10 @@ electron-notarize@^1.2.1: debug "^4.1.1" fs-extra "^9.0.1" -electron-osx-sign@^0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/electron-osx-sign/-/electron-osx-sign-0.5.0.tgz#fc258c5e896859904bbe3d01da06902c04b51c3a" - integrity sha512-icoRLHzFz/qxzDh/N4Pi2z4yVHurlsCAYQvsCSG7fCedJ4UJXBS6PoQyGH71IfcqKupcKeK7HX/NkyfG+v6vlQ== +electron-osx-sign@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/electron-osx-sign/-/electron-osx-sign-0.6.0.tgz#9b69c191d471d9458ef5b1e4fdd52baa059f1bb8" + integrity sha512-+hiIEb2Xxk6eDKJ2FFlpofCnemCbjbT5jz+BKGpVBrRNT3kWTGs4DfNX6IzGwgi33hUcXF+kFs9JW+r6Wc1LRg== dependencies: bluebird "^3.5.0" compare-version "^0.1.2" @@ -6768,14 +6718,14 @@ electron-osx-sign@^0.5.0: minimist "^1.2.0" plist "^3.0.1" -electron-publish@22.14.13: - version "22.14.13" - resolved "https://registry.yarnpkg.com/electron-publish/-/electron-publish-22.14.13.tgz#8b71e6975af8cc6ac5b21f293ade23f8704047c7" - integrity sha512-0oP3QiNj3e8ewOaEpEJV/o6Zrmy2VarVvZ/bH7kyO/S/aJf9x8vQsKVWpsdmSiZ5DJEHgarFIXrnO0ZQf0P9iQ== +electron-publish@23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/electron-publish/-/electron-publish-23.6.0.tgz#ac9b469e0b07752eb89357dd660e5fb10b3d1ce9" + integrity sha512-jPj3y+eIZQJF/+t5SLvsI5eS4mazCbNYqatv5JihbqOstIM13k0d1Z3vAWntvtt13Itl61SO6seicWdioOU5dg== dependencies: "@types/fs-extra" "^9.0.11" - builder-util "22.14.13" - builder-util-runtime "8.9.2" + builder-util "23.6.0" + builder-util-runtime "9.1.1" chalk "^4.1.1" fs-extra "^10.0.0" lazy-val "^1.0.5" @@ -7069,11 +7019,6 @@ escalade@^3.1.1: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== -escape-goat@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675" - integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q== - escape-html@^1.0.3, escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" @@ -7887,7 +7832,7 @@ fs-constants@^1.0.0: resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== -fs-extra@^10.0.0: +fs-extra@^10.0.0, fs-extra@^10.1.0: version "10.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" integrity sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ== @@ -8114,13 +8059,6 @@ global-agent@^3.0.0: semver "^7.3.2" serialize-error "^7.0.1" -global-dirs@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-3.0.1.tgz#0c488971f066baceda21447aecb1a8b911d22485" - integrity sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA== - dependencies: - ini "2.0.0" - global-modules@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780" @@ -8384,11 +8322,6 @@ has-unicode@^2.0.1: resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" integrity sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ== -has-yarn@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/has-yarn/-/has-yarn-2.1.0.tgz#137e11354a7b5bf11aa5cb649cf0c6f3ff2b2e77" - integrity sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw== - has@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" @@ -8423,7 +8356,7 @@ hoopy@^0.1.4: resolved "https://registry.yarnpkg.com/hoopy/-/hoopy-0.1.4.tgz#609207d661100033a9a9402ad3dea677381c1b1d" integrity sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ== -hosted-git-info@^4.0.2: +hosted-git-info@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-4.1.0.tgz#827b82867e9ff1c8d0c4d9d53880397d2c86d224" integrity sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA== @@ -8718,11 +8651,6 @@ import-fresh@^3.0.0, import-fresh@^3.1.0, import-fresh@^3.2.1: parent-module "^1.0.0" resolve-from "^4.0.0" -import-lazy@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43" - integrity sha512-m7ZEHgtw69qOGw+jwxXkHlrlIPdTGkyh66zXZ1ajZbxkDBNjSY/LGbmjc7h0s2ELsUDTAhFr55TrPSSqJGPG0A== - import-local@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.1.0.tgz#b4479df8a5fd44f6cdce24070675676063c95cb4" @@ -8769,11 +8697,6 @@ inherits@2.0.3: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw== -ini@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" - integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA== - ini@^1.3.4, ini@^1.3.5, ini@~1.3.0: version "1.3.8" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" @@ -8864,13 +8787,6 @@ is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== -is-ci@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" - integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== - dependencies: - ci-info "^2.0.0" - is-ci@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-3.0.1.tgz#db6ecbed1bd659c43dac0f45661e7674103d1867" @@ -8926,14 +8842,6 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: dependencies: is-extglob "^2.1.1" -is-installed-globally@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.4.0.tgz#9a0fd407949c30f86eb6959ef1b7994ed0b7b520" - integrity sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ== - dependencies: - global-dirs "^3.0.0" - is-path-inside "^3.0.2" - is-interactive@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e" @@ -8959,11 +8867,6 @@ is-negative-zero@^2.0.2: resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.2.tgz#7bf6f03a28003b8b3965de3ac26f664d765f3150" integrity sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA== -is-npm@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-5.0.0.tgz#43e8d65cc56e1b67f8d47262cf667099193f45a8" - integrity sha512-WW/rQLOazUq+ST/bCAVBp/2oMERWLsR7OrKyt052dNDk4DHcDE0/7QSXITlmi+VBcV13DfIbysG3tZJm5RfdBA== - is-number-object@^1.0.4: version "1.0.7" resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.7.tgz#59d50ada4c45251784e9904f5246c742f07a42fc" @@ -8981,12 +8884,7 @@ is-obj@^1.0.1: resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" integrity sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg== -is-obj@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982" - integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w== - -is-path-inside@^3.0.2, is-path-inside@^3.0.3: +is-path-inside@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== @@ -9110,11 +9008,6 @@ is-wsl@^2.2.0: dependencies: is-docker "^2.0.0" -is-yarn-global@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232" - integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw== - isarray@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" @@ -9132,7 +9025,7 @@ isbinaryfile@^3.0.2: dependencies: buffer-alloc "^1.2.0" -isbinaryfile@^4.0.8: +isbinaryfile@^4.0.10: version "4.0.10" resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.10.tgz#0c5b5e30c2557a2f06febd37b7322946aaee42b3" integrity sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw== @@ -10066,13 +9959,6 @@ language-tags@=1.0.5: dependencies: language-subtag-registry "~0.3.2" -latest-version@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-5.1.0.tgz#119dfe908fe38d15dfa43ecd13fa12ec8832face" - integrity sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA== - dependencies: - package-json "^6.3.0" - launch-editor@^2.6.0: version "2.6.0" resolved "https://registry.yarnpkg.com/launch-editor/-/launch-editor-2.6.0.tgz#4c0c1a6ac126c572bd9ff9a30da1d2cae66defd7" @@ -10840,10 +10726,10 @@ node-int64@^0.4.0: resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" integrity sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw== -node-mac-contacts@1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/node-mac-contacts/-/node-mac-contacts-1.5.0.tgz#a11799f6f60f7c90228a5e4a98371e24f4be57cc" - integrity sha512-uDemyO1rr6PTGwTVJmUKSeFICSNWeootWkz2qF84ehlJaQN3uJQ5LMT4hmjRBCbLzYI3OdvWaN11WD4XokCy0g== +node-mac-contacts@^1.7.2: + version "1.7.2" + resolved "https://registry.yarnpkg.com/node-mac-contacts/-/node-mac-contacts-1.7.2.tgz#6dfe8199cd8e0671d72109e44a15942966ec62c0" + integrity sha512-HWB2Tul9yrAuo/YKGy75NLpUp2sFgP86/Ur+VgPAODnCiPUkotDDK3mY9BrPm3CZ7J1kfp3gFscYlBZPM6Z5ug== dependencies: bindings "^1.5.0" node-addon-api "^3.0.2" @@ -11191,16 +11077,6 @@ p-try@^2.0.0, p-try@^2.1.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== -package-json@^6.3.0: - version "6.5.0" - resolved "https://registry.yarnpkg.com/package-json/-/package-json-6.5.0.tgz#6feedaca35e75725876d0b0e64974697fed145b0" - integrity sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ== - dependencies: - got "^9.6.0" - registry-auth-token "^4.0.0" - registry-url "^5.0.0" - semver "^6.2.0" - param-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.4.tgz#7d17fe4aa12bde34d4a77d91acfb6219caad01c5" @@ -12199,13 +12075,6 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f" integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA== -pupa@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.1.1.tgz#f5e8fd4afc2c5d97828faa523549ed8744a20d62" - integrity sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A== - dependencies: - escape-goat "^2.0.0" - pvtsutils@^1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/pvtsutils/-/pvtsutils-1.3.2.tgz#9f8570d132cdd3c27ab7d51a2799239bf8d8d5de" @@ -12296,7 +12165,7 @@ raw-body@^2.2.0: iconv-lite "0.4.24" unpipe "1.0.0" -rc@1.2.8, rc@^1.2.7, rc@^1.2.8: +rc@^1.2.7: version "1.2.8" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== @@ -12723,20 +12592,6 @@ regexpu-core@^5.3.1: unicode-match-property-ecmascript "^2.0.0" unicode-match-property-value-ecmascript "^2.1.0" -registry-auth-token@^4.0.0: - version "4.2.2" - resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-4.2.2.tgz#f02d49c3668884612ca031419491a13539e21fac" - integrity sha512-PC5ZysNb42zpFME6D/XlIgtNGdTl8bBOCw90xQLVMpzuuubJKYDWFAEuUNc+Cn8Z8724tg2SDhDRrkVEsqfDMg== - dependencies: - rc "1.2.8" - -registry-url@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-5.1.0.tgz#e98334b50d5434b81136b44ec638d9c2009c5009" - integrity sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw== - dependencies: - rc "^1.2.8" - regjsparser@^0.9.1: version "0.9.1" resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.9.1.tgz#272d05aa10c7c1f67095b1ff0addae8442fc5709" @@ -13074,13 +12929,6 @@ semver-compare@^1.0.0: resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" integrity sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow== -semver-diff@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-3.1.1.tgz#05f77ce59f325e00e2706afd67bb506ddb1ca32b" - integrity sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg== - dependencies: - semver "^6.3.0" - semver-regex@^3.1.2: version "3.1.4" resolved "https://registry.yarnpkg.com/semver-regex/-/semver-regex-3.1.4.tgz#13053c0d4aa11d070a2f2872b6b1e3ae1e1971b4" @@ -13096,13 +12944,18 @@ semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== -semver@^7.1.2, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8: +semver@^7.1.2, semver@^7.3.2, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8: version "7.3.8" resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798" integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A== dependencies: lru-cache "^6.0.0" +semver@~7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" + integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== + send@0.18.0: version "0.18.0" resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be" @@ -13241,6 +13094,13 @@ simple-get@^4.0.0: once "^1.3.1" simple-concat "^1.0.0" +simple-update-notifier@^1.0.7: + version "1.1.0" + resolved "https://registry.yarnpkg.com/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz#67694c121de354af592b347cdba798463ed49c82" + integrity sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg== + dependencies: + semver "~7.0.0" + sisteransi@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" @@ -13542,7 +13402,7 @@ string-natural-compare@^3.0.1: resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -14319,26 +14179,6 @@ update-browserslist-db@^1.0.10: escalade "^3.1.1" picocolors "^1.0.0" -update-notifier@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-5.1.0.tgz#4ab0d7c7f36a231dd7316cf7729313f0214d9ad9" - integrity sha512-ItnICHbeMh9GqUy31hFPrD1kcuZ3rpxDZbf4KUDavXwS0bW5m7SLbDQpGX3UYr072cbrF5hFUs3r5tUsPwjfHw== - dependencies: - boxen "^5.0.0" - chalk "^4.1.0" - configstore "^5.0.1" - has-yarn "^2.1.0" - import-lazy "^2.1.0" - is-ci "^2.0.0" - is-installed-globally "^0.4.0" - is-npm "^5.0.0" - is-yarn-global "^0.3.0" - latest-version "^5.1.0" - pupa "^2.1.1" - semver "^7.3.4" - semver-diff "^3.1.1" - xdg-basedir "^4.0.0" - uri-js@^4.2.2: version "4.4.1" resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" @@ -14783,13 +14623,6 @@ wide-align@^1.1.5: dependencies: string-width "^1.0.2 || 2 || 3 || 4" -widest-line@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-3.1.0.tgz#8292333bbf66cb45ff0de1603b136b7ae1496eca" - integrity sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg== - dependencies: - string-width "^4.0.0" - wildcard@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.0.tgz#a77d20e5200c6faaac979e4b3aadc7b3dd7f8fec" @@ -15026,11 +14859,6 @@ ws@~7.4.2: resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c" integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A== -xdg-basedir@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13" - integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q== - xml-name-validator@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" @@ -15143,6 +14971,19 @@ yargs@^17.0.1, yargs@^17.3.1: y18n "^5.0.5" yargs-parser "^21.1.1" +yargs@^17.5.1: + version "17.7.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + yauzl@^2.10.0: version "2.10.0" resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"