From 4360672b78149c2e069993a63cc57ea3c207d9d7 Mon Sep 17 00:00:00 2001
From: zlshames <zlshames@gmail.com>
Date: Sat, 6 Jul 2024 14:47:26 -0400
Subject: [PATCH 01/11] adds raw sigterm/sigint handlers

---
 packages/server/.gitignore  | 2 +-
 packages/server/src/main.ts | 9 +++++++++
 2 files changed, 10 insertions(+), 1 deletion(-)

diff --git a/packages/server/.gitignore b/packages/server/.gitignore
index ec05802f..9b093bb4 100644
--- a/packages/server/.gitignore
+++ b/packages/server/.gitignore
@@ -2,7 +2,7 @@ node_modules/
 !dist/
 dist/*
 !dist/dev-app-update.yml
-release/
+releases/
 .vscode
 .DS_Store
 **/*.log
diff --git a/packages/server/src/main.ts b/packages/server/src/main.ts
index b82c9944..69f6bcac 100644
--- a/packages/server/src/main.ts
+++ b/packages/server/src/main.ts
@@ -111,6 +111,15 @@ app.on("window-all-closed", () => {
  */
 app.on("before-quit", event => handleExit(event));
 
+process.on("SIGTERM", async () => {
+    log.debug("Received SIGTERM, exiting...");
+    await handleExit(null, { exit: false });
+});
+process.on("SIGINT", async () => {
+    log.debug("Received SIGINT, exiting...");
+    await handleExit(null, { exit: false });
+});
+
 /**
  * All code below this point has to do with the command-line functionality.
  * This is when you run the app via terminal, we want to give users the ability

From f6044d774b3309decac37ce640391989c3a01324 Mon Sep 17 00:00:00 2001
From: zlshames <zlshames@gmail.com>
Date: Sat, 6 Jul 2024 19:39:20 -0400
Subject: [PATCH 02/11] improved error logging

---
 packages/server/src/server/index.ts | 36 ++++++++++++++---------------
 1 file changed, 18 insertions(+), 18 deletions(-)

diff --git a/packages/server/src/server/index.ts b/packages/server/src/server/index.ts
index 04857b70..f9dd75dc 100644
--- a/packages/server/src/server/index.ts
+++ b/packages/server/src/server/index.ts
@@ -298,7 +298,7 @@ class BlueBubblesServer extends EventEmitter {
     setNotificationCount(count: number) {
         this.notificationCount = count;
 
-        if (this.repo.getConfig("dock_badge")) {
+        if (this.repo?.getConfig("dock_badge")) {
             app.setBadgeCount(this.notificationCount);
         }
     }
@@ -358,7 +358,7 @@ class BlueBubblesServer extends EventEmitter {
             this.logger.info("Initializing filesystem...");
             FileSystem.setup();
         } catch (ex: any) {
-            this.logger.error(`Failed to setup Filesystem! ${ex.message}`);
+            this.logger.error(`Failed to setup Filesystem! ${ex?.message ?? String(ex)}}`);
         }
 
         // Initialize and connect to the server database
@@ -432,7 +432,7 @@ class BlueBubblesServer extends EventEmitter {
             this.logger.info("Initializing connection to Google FCM...");
             this.fcm = new FCMService();
         } catch (ex: any) {
-            this.logger.error(`Failed to setup Google FCM service! ${ex.message}`);
+            this.logger.error(`Failed to setup Google FCM service! ${ex?.message ?? String(ex)}}`);
         }
     }
 
@@ -441,7 +441,7 @@ class BlueBubblesServer extends EventEmitter {
             this.logger.info("Initializing OAuth service...");
             this.oauthService = new OauthService();
         } catch (ex: any) {
-            this.logger.error(`Failed to setup OAuth service! ${ex.message}`);
+            this.logger.error(`Failed to setup OAuth service! ${ex?.message ?? String(ex)}}`);
         }
     }
 
@@ -452,7 +452,7 @@ class BlueBubblesServer extends EventEmitter {
             this.logger.info("Initializing up sockets...");
             this.httpService = new HttpService();
         } catch (ex: any) {
-            this.logger.error(`Failed to setup socket service! ${ex.message}`);
+            this.logger.error(`Failed to setup socket service! ${ex?.message ?? String(ex)}}`);
         }
 
         this.initOauthService();
@@ -461,7 +461,7 @@ class BlueBubblesServer extends EventEmitter {
             this.logger.info("Initializing helper service...");
             this.privateApi = new PrivateApiService();
         } catch (ex: any) {
-            this.logger.error(`Failed to setup helper service! ${ex.message}`);
+            this.logger.error(`Failed to setup helper service! ${ex?.message ?? String(ex)}}`);
         }
 
         try {
@@ -472,28 +472,28 @@ class BlueBubblesServer extends EventEmitter {
                 new ZrokService()
             ];
         } catch (ex: any) {
-            this.logger.error(`Failed to initialize proxy services! ${ex.message}`);
+            this.logger.error(`Failed to initialize proxy services! ${ex?.message ?? String(ex)}}`);
         }
 
         try {
             this.logger.info("Initializing Message Manager...");
             this.messageManager = new OutgoingMessageManager();
         } catch (ex: any) {
-            this.logger.error(`Failed to start Message Manager service! ${ex.message}`);
+            this.logger.error(`Failed to start Message Manager service! ${ex?.message ?? String(ex)}}`);
         }
 
         try {
             this.logger.info("Initializing Webhook Service...");
             this.webhookService = new WebhookService();
         } catch (ex: any) {
-            this.logger.error(`Failed to start Webhook service! ${ex.message}`);
+            this.logger.error(`Failed to start Webhook service! ${ex?.message ?? String(ex)}}`);
         }
 
         try {
             this.logger.info("Initializing Scheduled Messages Service...");
             this.scheduledMessages = new ScheduledMessagesService();
         } catch (ex: any) {
-            this.logger.error(`Failed to start Scheduled Message service! ${ex.message}`);
+            this.logger.error(`Failed to start Scheduled Message service! ${ex?.message ?? String(ex)}}`);
         }
     }
 
@@ -507,7 +507,7 @@ class BlueBubblesServer extends EventEmitter {
             this.httpService.initialize();
             this.httpService.start();
         } catch (ex: any) {
-            this.logger.error(`Failed to start HTTP service! ${ex.message}`);
+            this.logger.error(`Failed to start HTTP service! ${ex?.message ?? String(ex)}}`);
         }
 
         // Only start the oauth service if the tutorial isn't done
@@ -527,14 +527,14 @@ class BlueBubblesServer extends EventEmitter {
         try {
             await this.startProxyServices();
         } catch (ex: any) {
-            this.logger.error(`Failed to connect to proxy service! ${ex.message}`);
+            this.logger.error(`Failed to connect to proxy service! ${ex?.message ?? String(ex)}`);
         }
 
         try {
             this.logger.info("Starting Scheduled Messages service...");
             await this.scheduledMessages.start();
         } catch (ex: any) {
-            this.logger.error(`Failed to start Scheduled Messages service! ${ex.message}`);
+            this.logger.error(`Failed to start Scheduled Messages service! ${ex?.message ?? String(ex)}}`);
         }
 
         const privateApiEnabled = this.repo.getConfig("enable_private_api") as boolean;
@@ -553,7 +553,7 @@ class BlueBubblesServer extends EventEmitter {
             this.logger.info("Starting FCM service...");
             await this.fcm.start();
         } catch (ex: any) {
-            this.logger.error(`Failed to start FCM service! ${ex.message}`);
+            this.logger.error(`Failed to start FCM service! ${ex?.message ?? String(ex)}}`);
         }
     }
 
@@ -711,14 +711,14 @@ class BlueBubblesServer extends EventEmitter {
                 this.caffeinate.start();
             }
         } catch (ex: any) {
-            this.logger.error(`Failed to setup caffeinate service! ${ex.message}`);
+            this.logger.error(`Failed to setup caffeinate service! ${ex?.message ?? String(ex)}}`);
         }
 
         try {
             this.logger.info("Initializing queue service...");
             this.queue = new QueueService();
         } catch (ex: any) {
-            this.logger.error(`Failed to setup queue service! ${ex.message}`);
+            this.logger.error(`Failed to setup queue service! ${ex?.message ?? String(ex)}}`);
         }
 
         try {
@@ -743,7 +743,7 @@ class BlueBubblesServer extends EventEmitter {
 
             this.networkChecker.start();
         } catch (ex: any) {
-            this.logger.error(`Failed to setup network service! ${ex.message}`);
+            this.logger.error(`Failed to setup network service! ${ex?.message ?? String(ex)}}`);
         }
     }
 
@@ -950,7 +950,7 @@ class BlueBubblesServer extends EventEmitter {
                 await MacOsInterface.lock();
             }
         } catch (ex: any) {
-            this.logger.debug(`Failed to auto-lock Mac! ${ex.message}`);
+            this.logger.debug(`Failed to auto-lock Mac! ${ex?.message ?? String(ex)}}`);
         }
 
         this.logger.info("Finished post-start checks...");

From c531215a618cb1cd745668f795076523f718e8f2 Mon Sep 17 00:00:00 2001
From: zlshames <zlshames@gmail.com>
Date: Sat, 6 Jul 2024 19:39:36 -0400
Subject: [PATCH 03/11] time sync errors are now debug level

---
 packages/server/src/server/fileSystem/index.ts | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/packages/server/src/server/fileSystem/index.ts b/packages/server/src/server/fileSystem/index.ts
index fb3c9b12..cc3f299e 100644
--- a/packages/server/src/server/fileSystem/index.ts
+++ b/packages/server/src/server/fileSystem/index.ts
@@ -689,8 +689,8 @@ export class FileSystem {
                 return Number.parseFloat(time);
             }
         } catch (ex) {
-            Server().log("Failed to sync time with time servers!", "warn");
-            Server().log(ex);
+            Server().log("Failed to sync time with time servers!", "debug");
+            Server().log(ex, 'debug');
         }
 
         return null;

From 55dc7ba0b62d5f9e593908e92963114bfb42b9ad Mon Sep 17 00:00:00 2001
From: zlshames <zlshames@gmail.com>
Date: Sat, 6 Jul 2024 19:40:40 -0400
Subject: [PATCH 04/11] switches away from zx for cloudflare manager

---
 .../managers/cloudflareManager/index.ts       | 69 +++++++++++--------
 1 file changed, 40 insertions(+), 29 deletions(-)

diff --git a/packages/server/src/server/managers/cloudflareManager/index.ts b/packages/server/src/server/managers/cloudflareManager/index.ts
index 8230de84..7386bc30 100644
--- a/packages/server/src/server/managers/cloudflareManager/index.ts
+++ b/packages/server/src/server/managers/cloudflareManager/index.ts
@@ -1,10 +1,9 @@
-import { ProcessOutput, ProcessPromise } from "zx";
-import * as zx from "zx";
 import * as path from "path";
 import { FileSystem } from "@server/fileSystem";
 import { Server } from "@server";
 import { isEmpty, isNotEmpty } from "@server/helpers/utils";
 import { Loggable } from "@server/lib/logging/Loggable";
+import { ChildProcess, spawn } from "child_process";
 
 export class CloudflareManager extends Loggable {
     tag = "CloudflareManager";
@@ -17,7 +16,7 @@ export class CloudflareManager extends Loggable {
 
     pidPath = path.join(FileSystem.resources, "macos", "daemons", "cloudflare", "cloudflare.pid");
 
-    proc: ProcessPromise<ProcessOutput>;
+    proc: ChildProcess;
 
     currentProxyUrl: string;
 
@@ -39,34 +38,37 @@ export class CloudflareManager extends Loggable {
 
     private async connectHandler(): Promise<string> {
         return new Promise((resolve, reject) => {
-            const port = Server().repo.getConfig("socket_port") as string;
-            // eslint-disable-next-line max-len
-            this.proc = zx.$`${this.daemonPath} tunnel --url localhost:${port} --config ${this.cfgPath} --pidfile ${this.pidPath}`;
-
-            // If there is an error with the command, throw the error
-            this.proc.catch(reason => {
-                reject(reason);
-            });
-
-            // Configure handlers for all the output events
-            this.proc.stdout.on("data", chunk => this.handleData(chunk));
-            this.proc.stdout.on("error", chunk => this.handleError(chunk));
-            this.proc.stderr.on("data", chunk => this.handleData(chunk));
-            this.proc.stderr.on("error", chunk => this.handleError(chunk));
-
-            this.on("new-url", url => resolve(url));
-            this.on("error", err => {
-                // Ignore certain errors
-                if (typeof err === "string") {
-                    if (err.includes("Thank you for trying Cloudflare Tunnel.")) return;
-                }
+            try {
+                const port = Server().repo.getConfig("socket_port") as string;
+                this.proc = spawn(this.daemonPath, [
+                    'tunnel',
+                    '--url', `localhost:${port}`,
+                    '--config', this.cfgPath,
+                    '--pidfile', this.pidPath
+                ]);
+
+                // Configure handlers for all the output events
+                this.proc.stdout.on("data", chunk => this.handleData(chunk));
+                this.proc.stdout.on("error", chunk => this.handleError(chunk));
+                this.proc.stderr.on("data", chunk => this.handleData(chunk));
+                this.proc.stderr.on("error", chunk => this.handleError(chunk));
+
+                this.on("new-url", url => resolve(url));
+                this.on("error", err => {
+                    // Ignore certain errors
+                    if (typeof err === "string") {
+                        if (err.includes("Thank you for trying Cloudflare Tunnel.")) return;
+                    }
 
-                reject(err);
-            });
+                    reject(err);
+                });
 
-            setTimeout(() => {
-                reject(new Error("Failed to connect to Cloudflare after 2 minutes..."));
-            }, 1000 * 60 * 2); // 2 minutes
+                setTimeout(() => {
+                    reject(new Error("Failed to connect to Cloudflare after 2 minutes..."));
+                }, 1000 * 60 * 2); // 2 minutes
+            } catch (ex) {
+                reject(ex);
+            }
         });
     }
 
@@ -90,10 +92,19 @@ export class CloudflareManager extends Loggable {
             return;
         }
 
+        this.detectErrors(data);
         this.detectNewUrl(data);
         this.detectMaxConnectionRetries(data);
     }
 
+    private detectErrors(data: string) {
+        if (data.includes('no such host')) {
+            this.handleError('Unable to resolve api.trycloudflare.com! Ensure that your Mac has internet access and that any networking tools you use are not blocking the hostname.')
+        } else if (data.includes('failed to request quick Tunnel: ')) {
+            this.handleError(data.split('failed to request quick Tunnel: ')[1]);
+        }
+    }
+
     private detectNewUrl(data: string) {
         const urlMatches = data.match(this.proxyUrlRegex);
         if (isNotEmpty(urlMatches)) this.setProxyUrl(urlMatches[1]);

From baeedb3cdd98ac9c1b4dbd65ca79e1c14d612fa8 Mon Sep 17 00:00:00 2001
From: zlshames <zlshames@gmail.com>
Date: Sat, 6 Jul 2024 19:40:54 -0400
Subject: [PATCH 05/11] graceful error handling for update service

---
 .../server/services/updateService/index.ts    | 23 ++++++++++++-------
 1 file changed, 15 insertions(+), 8 deletions(-)

diff --git a/packages/server/src/server/services/updateService/index.ts b/packages/server/src/server/services/updateService/index.ts
index 4927fd6c..f04dda71 100644
--- a/packages/server/src/server/services/updateService/index.ts
+++ b/packages/server/src/server/services/updateService/index.ts
@@ -4,7 +4,7 @@ import { Server } from "@server";
 import { SERVER_UPDATE } from "@server/events";
 import { ScheduledService } from "@server/lib/ScheduledService";
 import { Loggable } from "@server/lib/logging/Loggable";
-import axios from "axios";
+import axios, { AxiosResponse } from "axios";
 
 export class UpdateService extends Loggable {
     tag = "UpdateService";
@@ -52,14 +52,21 @@ export class UpdateService extends Loggable {
     }
 
     async checkForUpdate({ showNoUpdateDialog = false, showUpdateDialog = true } = {}): Promise<boolean> {
-        const releasesRes = await axios.get(
-            "https://api.github.com/repos/BlueBubblesApp/bluebubbles-server/releases",
-            {
-                headers: {
-                    Accept: "application/vnd.github.v3+json"
+        let releasesRes: AxiosResponse<any, any>;
+
+        try {
+            releasesRes = await axios.get(
+                "https://api.github.com/repos/BlueBubblesApp/bluebubbles-server/releases",
+                {
+                    headers: {
+                        Accept: "application/vnd.github.v3+json"
+                    }
                 }
-            }
-        );
+            );
+        } catch (ex: any) {
+            this.log.error(`Failed to fetch release information from GitHub! Error: ${ex?.message ?? String(ex)}`);
+            return false;
+        }
 
         const releases = (releasesRes.data as any[]).filter((x) =>
             !x.prerelease &&

From 9a32a3f54b71f83e9cede7ee1e49b0b45867ab79 Mon Sep 17 00:00:00 2001
From: zlshames <zlshames@gmail.com>
Date: Sat, 6 Jul 2024 19:44:13 -0400
Subject: [PATCH 06/11] start delay only occurs when uptime is < 5 minutes now

---
 packages/server/src/server/index.ts | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/packages/server/src/server/index.ts b/packages/server/src/server/index.ts
index f9dd75dc..2b1820d2 100644
--- a/packages/server/src/server/index.ts
+++ b/packages/server/src/server/index.ts
@@ -370,8 +370,10 @@ class BlueBubblesServer extends EventEmitter {
         this.logger.info("Starting IPC Listeners..");
         IPCService.startIpcListeners();
 
+        // Delay will only occur if your Mac started up within the last 5 minutes
         const startDelay: number = getStartDelay();
-        if (startDelay > 0) {
+        const uptimeSeconds = os.uptime();
+        if (startDelay > 0 && uptimeSeconds < 300) {
             this.logger.info(`Delaying server startup by ${startDelay} seconds`);
             await waitMs(startDelay * 1000);
         }

From da787ab868d65433a836d7272c77198424575dc9 Mon Sep 17 00:00:00 2001
From: zlshames <zlshames@gmail.com>
Date: Sat, 6 Jul 2024 19:49:24 -0400
Subject: [PATCH 07/11] better error handling for cloudflare manager

---
 .../managers/cloudflareManager/index.ts       | 19 ++++++++++++++-----
 1 file changed, 14 insertions(+), 5 deletions(-)

diff --git a/packages/server/src/server/managers/cloudflareManager/index.ts b/packages/server/src/server/managers/cloudflareManager/index.ts
index 7386bc30..4ce7b88b 100644
--- a/packages/server/src/server/managers/cloudflareManager/index.ts
+++ b/packages/server/src/server/managers/cloudflareManager/index.ts
@@ -92,17 +92,21 @@ export class CloudflareManager extends Loggable {
             return;
         }
 
-        this.detectErrors(data);
+        const error = this.detectError(data);
+        if (error) this.emitError(error);
+
         this.detectNewUrl(data);
         this.detectMaxConnectionRetries(data);
     }
 
-    private detectErrors(data: string) {
+    private detectError(data: string): string | null {
         if (data.includes('no such host')) {
-            this.handleError('Unable to resolve api.trycloudflare.com! Ensure that your Mac has internet access and that any networking tools you use are not blocking the hostname.')
+            return 'Unable to resolve api.trycloudflare.com! Ensure that your Mac has internet access and that any networking tools you use are not blocking the hostname.';
         } else if (data.includes('failed to request quick Tunnel: ')) {
-            this.handleError(data.split('failed to request quick Tunnel: ')[1]);
+            return data.split('failed to request quick Tunnel: ')[1];
         }
+
+        return null;
     }
 
     private detectNewUrl(data: string) {
@@ -136,6 +140,11 @@ export class CloudflareManager extends Loggable {
     }
 
     handleError(chunk: any) {
-        this.emit("error", chunk);
+        const error = this.detectError(chunk);
+        if (error) this.emitError(error);
+    }
+
+    emitError(err: any) {
+        this.emit("error", err);
     }
 }

From 6fe1b5363d45079a8601c787eda2bd8e867d8456 Mon Sep 17 00:00:00 2001
From: zlshames <zlshames@gmail.com>
Date: Tue, 9 Jul 2024 09:59:36 -0400
Subject: [PATCH 08/11] fix: improves firebase setup slightly

---
 packages/server/src/server/services/oauthService/index.ts | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/packages/server/src/server/services/oauthService/index.ts b/packages/server/src/server/services/oauthService/index.ts
index b337dd43..bec69166 100644
--- a/packages/server/src/server/services/oauthService/index.ts
+++ b/packages/server/src/server/services/oauthService/index.ts
@@ -513,7 +513,10 @@ export class OauthService extends Loggable {
         try {
             const url = `https://firebase.googleapis.com/v1beta1/projects/${projectId}/androidApps`;
             const data = { displayName: this.projectName, packageName: this.packageName };
-            const createRes = await this.sendRequest("POST", url, data);
+            const createRes = await this.tryUntilNoError("POST", url, data, 3, 10000);
+            if (!createRes?.data?.name) {
+                throw new Error(`Failed to provision Android App: ${getObjectAsString(createRes)}`);
+            }
 
             // Wait for the app to be created
             const operationName = createRes.data.name;

From 4e03f6338f7e7845d3d157c72dda81c1f32034e4 Mon Sep 17 00:00:00 2001
From: zlshames <zlshames@gmail.com>
Date: Tue, 9 Jul 2024 10:00:13 -0400
Subject: [PATCH 09/11] fix fcm start condition

---
 packages/server/src/server/services/fcmService/index.ts | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/packages/server/src/server/services/fcmService/index.ts b/packages/server/src/server/services/fcmService/index.ts
index 288d23b0..3baedb2e 100644
--- a/packages/server/src/server/services/fcmService/index.ts
+++ b/packages/server/src/server/services/fcmService/index.ts
@@ -257,7 +257,7 @@ export class FCMService extends Loggable {
         if (isEmpty(serverUrl)) return;
 
         // Make sure that if we haven't initialized, we do so
-        if (!this.hasInitialized || !(await this.start())) return;
+        if (!this.hasInitialized && !(await this.start())) return;
 
         this.log.debug(`Attempting to write server URL to database...`);
         await this.saveUrlToDb(serverUrl);
@@ -449,7 +449,7 @@ export class FCMService extends Loggable {
         priority: "normal" | "high" = "normal"
     ): Promise<admin.messaging.BatchResponse> {
         try {
-            if (!this.hasInitialized || !(await this.start())) return null;
+            if (!this.hasInitialized && !(await this.start())) return null;
 
             // Build out the notification message
             const payload: admin.messaging.MulticastMessage = {

From 8ee08bafbd1a3a82769f5ab7780b38f2928957a6 Mon Sep 17 00:00:00 2001
From: zlshames <zlshames@gmail.com>
Date: Tue, 9 Jul 2024 10:05:45 -0400
Subject: [PATCH 10/11] migrated away from zx due to leak

---
 package-lock.json                             | 172 +----------
 packages/server/package.json                  |   3 +-
 packages/server/src/server/api/http/index.ts  |  14 +-
 .../privateApi/modes/dylibPlugins/index.ts    |  40 ++-
 .../server/src/server/lib/ProcessSpawner.ts   | 291 ++++++++++++++++++
 .../managers/cloudflareManager/index.ts       |  46 +--
 .../src/server/managers/zrokManager/index.ts  |  83 ++---
 7 files changed, 388 insertions(+), 261 deletions(-)
 create mode 100644 packages/server/src/server/lib/ProcessSpawner.ts

diff --git a/package-lock.json b/package-lock.json
index 45aedc86..39c48214 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6139,6 +6139,7 @@
             "version": "9.0.13",
             "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz",
             "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==",
+            "dev": true,
             "dependencies": {
                 "@types/node": "*"
             }
@@ -6369,11 +6370,6 @@
             "integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==",
             "dev": true
         },
-        "node_modules/@types/minimist": {
-            "version": "1.2.5",
-            "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz",
-            "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag=="
-        },
         "node_modules/@types/ms": {
             "version": "0.7.34",
             "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz",
@@ -6385,15 +6381,6 @@
             "resolved": "https://registry.npmjs.org/@types/node/-/node-13.13.52.tgz",
             "integrity": "sha512-s3nugnZumCC//n4moGGe6tkNMyYEdaDBitVjwPxXmR5lnMG5dHePinH2EdxkG3Rh1ghFHHixAG4NJhpJW1rthQ=="
         },
-        "node_modules/@types/node-fetch": {
-            "version": "2.6.11",
-            "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.11.tgz",
-            "integrity": "sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==",
-            "dependencies": {
-                "@types/node": "*",
-                "form-data": "^4.0.0"
-            }
-        },
         "node_modules/@types/node-forge": {
             "version": "1.3.11",
             "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.11.tgz",
@@ -11449,7 +11436,8 @@
         "node_modules/duplexer": {
             "version": "0.1.2",
             "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
-            "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
+            "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==",
+            "peer": true
         },
         "node_modules/duplexify": {
             "version": "4.1.3",
@@ -13132,20 +13120,6 @@
                 "node": ">= 0.6"
             }
         },
-        "node_modules/event-stream": {
-            "version": "3.3.4",
-            "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz",
-            "integrity": "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==",
-            "dependencies": {
-                "duplexer": "~0.1.1",
-                "from": "~0",
-                "map-stream": "~0.1.0",
-                "pause-stream": "0.0.11",
-                "split": "0.3",
-                "stream-combiner": "~0.0.4",
-                "through": "~2.3.1"
-            }
-        },
         "node_modules/event-target-shim": {
             "version": "5.0.1",
             "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
@@ -14062,11 +14036,6 @@
                 "node": ">= 0.6"
             }
         },
-        "node_modules/from": {
-            "version": "0.1.7",
-            "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz",
-            "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g=="
-        },
         "node_modules/fs-constants": {
             "version": "1.0.0",
             "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -18437,11 +18406,6 @@
                 "tmpl": "1.0.5"
             }
         },
-        "node_modules/map-stream": {
-            "version": "0.1.0",
-            "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz",
-            "integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g=="
-        },
         "node_modules/matcher": {
             "version": "3.0.0",
             "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
@@ -19765,14 +19729,6 @@
                 "node": ">=8"
             }
         },
-        "node_modules/pause-stream": {
-            "version": "0.0.11",
-            "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz",
-            "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==",
-            "dependencies": {
-                "through": "~2.3"
-            }
-        },
         "node_modules/pend": {
             "version": "1.2.0",
             "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
@@ -21533,20 +21489,6 @@
             "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
             "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw=="
         },
-        "node_modules/ps-tree": {
-            "version": "1.2.0",
-            "resolved": "https://registry.npmjs.org/ps-tree/-/ps-tree-1.2.0.tgz",
-            "integrity": "sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==",
-            "dependencies": {
-                "event-stream": "=3.3.4"
-            },
-            "bin": {
-                "ps-tree": "bin/ps-tree.js"
-            },
-            "engines": {
-                "node": ">= 0.10"
-            }
-        },
         "node_modules/psl": {
             "version": "1.9.0",
             "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
@@ -23943,17 +23885,6 @@
                 "wbuf": "^1.7.3"
             }
         },
-        "node_modules/split": {
-            "version": "0.3.3",
-            "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz",
-            "integrity": "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==",
-            "dependencies": {
-                "through": "2"
-            },
-            "engines": {
-                "node": "*"
-            }
-        },
         "node_modules/sprintf-js": {
             "version": "1.1.3",
             "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
@@ -24135,14 +24066,6 @@
                 "node": ">= 0.4"
             }
         },
-        "node_modules/stream-combiner": {
-            "version": "0.0.4",
-            "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz",
-            "integrity": "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==",
-            "dependencies": {
-                "duplexer": "~0.1.1"
-            }
-        },
         "node_modules/stream-events": {
             "version": "1.0.5",
             "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz",
@@ -25157,7 +25080,8 @@
         "node_modules/through": {
             "version": "2.3.8",
             "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
-            "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="
+            "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
+            "dev": true
         },
         "node_modules/thunky": {
             "version": "1.1.0",
@@ -27327,89 +27251,6 @@
                 "node": ">= 10"
             }
         },
-        "node_modules/zx": {
-            "version": "4.3.0",
-            "resolved": "https://registry.npmjs.org/zx/-/zx-4.3.0.tgz",
-            "integrity": "sha512-KuEjpu5QFIMx0wWfzknDRhY98s7a3tWNRmYt19XNmB7AfOmz5zISA4+3Q8vlJc2qguxMn89uSxhPDCldPa3YLA==",
-            "dependencies": {
-                "@types/fs-extra": "^9.0.12",
-                "@types/minimist": "^1.2.2",
-                "@types/node": "^16.6",
-                "@types/node-fetch": "^2.5.12",
-                "chalk": "^4.1.2",
-                "fs-extra": "^10.0.0",
-                "globby": "^12.0.1",
-                "minimist": "^1.2.5",
-                "node-fetch": "^2.6.1",
-                "ps-tree": "^1.2.0",
-                "which": "^2.0.2"
-            },
-            "bin": {
-                "zx": "zx.mjs"
-            },
-            "engines": {
-                "node": ">= 14.13.1"
-            }
-        },
-        "node_modules/zx/node_modules/@types/node": {
-            "version": "16.18.101",
-            "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.101.tgz",
-            "integrity": "sha512-AAsx9Rgz2IzG8KJ6tXd6ndNkVcu+GYB6U/SnFAaokSPNx2N7dcIIfnighYUNumvj6YS2q39Dejz5tT0NCV7CWA=="
-        },
-        "node_modules/zx/node_modules/array-union": {
-            "version": "3.0.1",
-            "resolved": "https://registry.npmjs.org/array-union/-/array-union-3.0.1.tgz",
-            "integrity": "sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw==",
-            "engines": {
-                "node": ">=12"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/sindresorhus"
-            }
-        },
-        "node_modules/zx/node_modules/fs-extra": {
-            "version": "10.1.0",
-            "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
-            "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
-            "dependencies": {
-                "graceful-fs": "^4.2.0",
-                "jsonfile": "^6.0.1",
-                "universalify": "^2.0.0"
-            },
-            "engines": {
-                "node": ">=12"
-            }
-        },
-        "node_modules/zx/node_modules/globby": {
-            "version": "12.2.0",
-            "resolved": "https://registry.npmjs.org/globby/-/globby-12.2.0.tgz",
-            "integrity": "sha512-wiSuFQLZ+urS9x2gGPl1H5drc5twabmm4m2gTR27XDFyjUHJUNsS8o/2aKyIF6IoBaR630atdher0XJ5g6OMmA==",
-            "dependencies": {
-                "array-union": "^3.0.1",
-                "dir-glob": "^3.0.1",
-                "fast-glob": "^3.2.7",
-                "ignore": "^5.1.9",
-                "merge2": "^1.4.1",
-                "slash": "^4.0.0"
-            },
-            "engines": {
-                "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/sindresorhus"
-            }
-        },
-        "node_modules/zx/node_modules/slash": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz",
-            "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==",
-            "engines": {
-                "node": ">=12"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/sindresorhus"
-            }
-        },
         "packages/server": {
             "name": "@bluebubbles/server",
             "version": "1.9.8",
@@ -27459,8 +27300,7 @@
                 "typeorm": "^0.3.20",
                 "uuid": "^9.0.1",
                 "validatorjs": "^3.22.1",
-                "vcf": "^2.1.1",
-                "zx": "^4.3.0"
+                "vcf": "^2.1.1"
             },
             "devDependencies": {
                 "@babel/core": "^7.16.12",
diff --git a/packages/server/package.json b/packages/server/package.json
index 59a9a729..918a4ad9 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -125,7 +125,6 @@
         "typeorm": "^0.3.20",
         "uuid": "^9.0.1",
         "validatorjs": "^3.22.1",
-        "vcf": "^2.1.1",
-        "zx": "^4.3.0"
+        "vcf": "^2.1.1"
     }
 }
diff --git a/packages/server/src/server/api/http/index.ts b/packages/server/src/server/api/http/index.ts
index a2223027..f28a2d90 100644
--- a/packages/server/src/server/api/http/index.ts
+++ b/packages/server/src/server/api/http/index.ts
@@ -10,7 +10,6 @@ import koaCors from "koa-cors";
 import * as https from "https";
 import * as http from "http";
 import * as fs from "fs";
-import * as zx from "zx";
 
 // Internal libraries
 import { Server } from "@server";
@@ -26,6 +25,7 @@ import { HELLO_WORLD } from "@server/events";
 import { ScheduledService } from "../../lib/ScheduledService";
 import { Loggable } from "../../lib/logging/Loggable";
 import { ProxyServices } from "@server/databases/server/constants";
+import { ProcessSpawner } from "@server/lib/ProcessSpawner";
 
 /**
  * This service class handles all routing for incoming socket
@@ -152,9 +152,15 @@ export class HttpService extends Loggable {
     async checkIfPortInUse(port: number) {
         try {
             // Check if there are any listening services
-            zx.$.verbose = false;
-            const output = await zx.$`lsof -nP -iTCP -sTCP:LISTEN | grep ${port}`;
-            if (output.toString().includes(`:${port} (LISTEN)`)) return true;
+            const output = await ProcessSpawner.executeCommand('lsof', [
+                '-nP',
+                '-iTCP',
+                '-sTCP:LISTEN',
+                '|',
+                'grep',
+                `${port}`
+            ], {}, "PortChecker");
+            if (output.includes(`:${port} (LISTEN)`)) return true;
         } catch {
             // Don't show an error, I believe this throws a "false error".
             // For instance, if the proxy service doesn't start, and the command returns
diff --git a/packages/server/src/server/api/privateApi/modes/dylibPlugins/index.ts b/packages/server/src/server/api/privateApi/modes/dylibPlugins/index.ts
index 1054a697..483367ea 100644
--- a/packages/server/src/server/api/privateApi/modes/dylibPlugins/index.ts
+++ b/packages/server/src/server/api/privateApi/modes/dylibPlugins/index.ts
@@ -1,11 +1,10 @@
 import fs from "fs";
 import { waitMs } from "@server/helpers/utils";
 import { Server } from "@server";
-import * as zx from "zx";
-import { ProcessPromise, $ } from "zx";
 import { FileSystem } from "@server/fileSystem";
 import { hideApp } from "@server/api/apple/scripts";
 import { Loggable } from "@server/lib/logging/Loggable";
+import { ProcessSpawner } from "@server/lib/ProcessSpawner";
 
 export abstract class DylibPlugin extends Loggable {
     tag = "DylibPlugin";
@@ -20,8 +19,6 @@ export abstract class DylibPlugin extends Loggable {
 
     isInjecting = false;
 
-    dylibProcess: ProcessPromise<any>;
-
     dylibFailureCounter = 0;
 
     dylibLastErrorTime = 0;
@@ -93,9 +90,6 @@ export abstract class DylibPlugin extends Loggable {
             throw new Error(`Unable to locate ${this.name} parent process!`);
         }
 
-        // Don't log the output to stdout
-        zx.$.verbose = false;
-
         while (this.dylibFailureCounter < 5) {
             try {
                 // Stop the running Messages app
@@ -109,14 +103,22 @@ export abstract class DylibPlugin extends Loggable {
                     return;
                 }
 
-                this.dylibProcess = zx.$`DYLD_INSERT_LIBRARIES=${this.dylibPath} ${this.parentProcessPath}`;
-                this.dylibProcess.stdout.on("data", (data: string) => {
-                    this.log.debug(`DYLIB: ${data}`);
-                });
-
-                this.dylibProcess.stderr.on("data", (data: string) => {
-                    this.log.debug(`DYLIB: ${data}`);
-                });
+                const spawner = new ProcessSpawner({
+                    command: this.parentProcessPath,
+                    args: [],
+                    verbose: true,
+                    logTag: this.parentApp,
+                    storeOutput: false,
+                    waitForExit: true,
+                    restartOnNonZeroExit: false,
+                    options: {
+                        env: {
+                            DYLD_INSERT_LIBRARIES: this.dylibPath
+                        }
+                    },
+                })
+
+                const promise = spawner.execute();
 
                 // Hide the app after 5 seconds
                 setTimeout(() => {
@@ -128,9 +130,13 @@ export abstract class DylibPlugin extends Loggable {
                     onSuccessfulStart();
                 });
 
-                await this.dylibProcess;
+                await promise;
+
+                // If it gets here, the dylib exited on its own (code: 0)
+                this.log.debug(`DYLIB exited on its own. Restarting...`);
+                this.dylibFailureCounter = 0;
             } catch (ex: any) {
-                this.log.debug(`Detected DYLIB crash for App ${this.parentApp}. Error: ${ex}`);
+                this.log.debug(`Detected DYLIB crash for App ${this.parentApp}. Error: ${ex?.message ?? String(ex)}`);
                 if (this.isStopping) {
                     this.isInjecting = false;
                     return;
diff --git a/packages/server/src/server/lib/ProcessSpawner.ts b/packages/server/src/server/lib/ProcessSpawner.ts
new file mode 100644
index 00000000..870b8b53
--- /dev/null
+++ b/packages/server/src/server/lib/ProcessSpawner.ts
@@ -0,0 +1,291 @@
+import { ChildProcess, SpawnOptionsWithoutStdio, spawn } from "child_process";
+import { Loggable } from "./logging/Loggable";
+
+
+export type ProcessSpawnerConstructorArgs = {
+    command: string;
+    args?: string[];
+    options?: SpawnOptionsWithoutStdio;
+    verbose?: boolean;
+    onOutput?: ((data: any) => void) | null;
+    onExit?: ((code: number) => void) | null;
+    logTag?: string | null;
+    restartOnNonZeroExit?: boolean;
+    storeOutput?: boolean;
+    waitForExit?: boolean;
+    errorOnStderr?: boolean;
+};
+
+export type ProcessSpawnerError = {
+    message: string;
+    output: string;
+    process: ChildProcess;
+    wasForceQuit: boolean;
+};
+
+type ProcessSpawnerOutput = {
+    type: "stdout" | "stderr";
+    data: string;
+};
+
+
+export class ProcessSpawner extends Loggable {
+    tag = "ProcessSpawner";
+
+    command: string;
+
+    args: string[];
+
+    options: SpawnOptionsWithoutStdio;
+
+    verbose: boolean;
+
+    process: ChildProcess;
+
+    restartOnNonZeroExit: boolean;
+
+    storeOutput: boolean;
+
+    waitForExit: boolean;
+    
+    errorOnStderr: boolean;
+
+    onOutput: ((data: any) => void) | null;
+
+    onExit: ((code: number) => void) | null;
+
+    private _output: ProcessSpawnerOutput[] = [];
+
+    get output() {
+        // Consecutive log outputs of the same type should be appended to the same line.
+        // If the type changes, we should add a newline.
+        let result = "";
+        let prevType = null;
+        for (const item of this._output) {
+            if (prevType !== null && prevType != item.type) {
+                result += `\n${item.data}`;
+                prevType = item.type;
+                continue;
+            }
+
+            result += item.data;
+            prevType = item.type;
+        }
+
+        return result.trim();
+    }
+
+    get stdout() {
+        return this._output.filter(x => x.type === "stdout").map(x => x.data).join("").trim();
+    }
+
+    get stderr() {
+        return this._output.filter(x => x.type === "stderr").map(x => x.data).join("").trim();
+    }
+
+    constructor({
+        command,
+        args = [],
+        options = {},
+        verbose = false,
+        onOutput = null,
+        onExit = null,
+        logTag = null,
+        restartOnNonZeroExit = false,
+        storeOutput = true,
+        waitForExit = true,
+        errorOnStderr = false
+    }: ProcessSpawnerConstructorArgs) {
+        super();
+        
+        this.command = command;
+        this.args = args;
+        this.options = options;
+        this.verbose = verbose;
+        this.onOutput = onOutput;
+        this.onExit = onExit;
+        this.restartOnNonZeroExit = restartOnNonZeroExit;
+        this.storeOutput = storeOutput;
+        this.waitForExit = waitForExit;
+        this.errorOnStderr = errorOnStderr;
+
+        if (logTag) {
+            this.tag = logTag;
+        }
+
+        if (this.restartOnNonZeroExit && this.waitForExit) {
+            throw new Error("Cannot use 'restartOnNonZeroExit' and 'waitForExit' together!");
+        }
+    }
+
+    async execute(): Promise<ProcessSpawner> {
+        return new Promise((resolve: (spawner: ProcessSpawner) => void, reject: (err: ProcessSpawnerError) => void) => {
+            try {
+                this.process = this.spawnProcesses();
+                this.process.stdout.on("data", chunk => this.handleOutput(chunk, "stdout"));
+                this.process.stderr.on("data", chunk => {
+                    this.handleOutput(chunk, "stderr");
+
+                    if (this.errorOnStderr) {
+                        reject({
+                            message: chunk.toString(),
+                            output: this.output,
+                            process: this.process,
+                            wasForceQuit: false
+                        });
+                    }
+                });
+                this.process.on("exit", (code) => {
+                    let msg = `Process was force quit`;
+                    let wasForceQuit = true;
+                    if (code != null) {
+                        msg = `Process exited with code: ${code}`;
+                        wasForceQuit = false;
+                    }
+
+                    this.handleLog(msg);
+                    this.handleExit(code);
+
+                    if (this.waitForExit) {
+                        if (!this.restartOnNonZeroExit && code !== 0) {
+                            reject({
+                                message: msg,
+                                output: this.output,
+                                process: this.process,
+                                wasForceQuit
+                            });
+                        } else {
+                            resolve(this);
+                        }
+                    }
+                });
+
+                if (!this.waitForExit) {
+                    resolve(this);
+                }
+            } catch (ex: any) {
+                reject({
+                    message: ex?.message ?? String(ex),
+                    output: this.output,
+                    process: this.process,
+                    wasForceQuit: false
+                });
+            }
+        });
+    }
+
+    private spawnProcesses() {
+        // If the args contain a pipe character, we need to split the command and args into separate processes.
+        // The separate processes should dynamically pipe the result into the next, returning the last process
+        // as the final result.
+        if (this.args.some(arg => arg.includes("|"))) {
+            // Combine the command and args into a single string
+            const commandStr = `${this.command} ${this.args.join(" ")}`;
+
+            // Split by the pipe character, and trim any whitespace
+            const commands = commandStr.split("|").map(x => x.trim());
+            if (commands.length < 2) {
+                throw new Error(`Invalid pipe command! Input: ${commandStr}`);
+            }
+
+            // Iterate over the commands, executing them and piping the
+            // output to the next process. Then return the last process.
+            let lastProcess: ChildProcess = null;
+            for (let i = 0; i < commands.length; i++) {
+                const command = commands[i].trim();
+
+                // Get the command
+                if (!command) {
+                    throw new Error(`Invalid command! Input: ${command}`);
+                }
+
+                // Pull the first command off the list
+                const commandParts = command.split(" ");
+                const program = commandParts[0];
+                const args = commandParts.slice(1);
+
+                // Spawn the process and pipe the output to the next process
+                const proc = spawn(program, this.quoteArgs(args), {
+                    stdio: [
+                        // If there is a previous process, pipe the output to the next process
+                        (lastProcess) ? lastProcess.stdout : "pipe",
+                        "pipe",
+                        "pipe"
+                    ]
+                });
+
+                lastProcess = proc;
+            }
+
+            return lastProcess;
+        }
+
+        return spawn(this.command, this.quoteArgs(this.args), this.options);
+    }
+
+    private handleLog(log: string) {
+        if (this.verbose) {
+            this.log.debug(log);
+        }
+    }
+
+    async handleOutput(chunk: any, type: "stdout" | "stderr") {
+        const chunkStr = chunk.toString();
+        this.handleLog(`[${type}] ${chunkStr}`);
+
+        if (this.storeOutput) {
+            this._output.push({ type, data: chunkStr });
+        }
+        
+        if (this.onOutput) {
+            this.onOutput(chunkStr);
+        }
+    }
+
+    async handleExit(code: number) {
+        if (this.onExit) {
+            this.onExit(code);
+        }
+
+        if (code !== 0 && this.restartOnNonZeroExit) {
+            this.execute();
+        }
+    }
+
+    async kill() {
+        if (this.process) {
+            this.process.kill();
+        }
+    }
+
+    private quoteArgs(args: string[]): string[] {
+        return args.map(arg => {
+            if (arg.includes(" ")) {
+                return `"${arg.replace(/"/g, '\\"')}"`;
+            } else {
+                return arg;
+            }
+        });
+    }
+
+    static async executeCommand(
+        command: string,
+        args: string[] = [],
+        options: SpawnOptionsWithoutStdio = {},
+        tag = 'CommandExecutor'
+    ): Promise<string> {
+        const spawner = new ProcessSpawner({
+            command,
+            args,
+            logTag: tag,
+            options,
+            verbose: false,
+            restartOnNonZeroExit: false,
+            storeOutput: true,
+            waitForExit: true
+        });
+
+        await spawner.execute();
+        return spawner.output;
+    }
+}
\ No newline at end of file
diff --git a/packages/server/src/server/managers/cloudflareManager/index.ts b/packages/server/src/server/managers/cloudflareManager/index.ts
index 4ce7b88b..13a89efb 100644
--- a/packages/server/src/server/managers/cloudflareManager/index.ts
+++ b/packages/server/src/server/managers/cloudflareManager/index.ts
@@ -3,7 +3,7 @@ import { FileSystem } from "@server/fileSystem";
 import { Server } from "@server";
 import { isEmpty, isNotEmpty } from "@server/helpers/utils";
 import { Loggable } from "@server/lib/logging/Loggable";
-import { ChildProcess, spawn } from "child_process";
+import { ProcessSpawner } from "@server/lib/ProcessSpawner";
 
 export class CloudflareManager extends Loggable {
     tag = "CloudflareManager";
@@ -16,7 +16,7 @@ export class CloudflareManager extends Loggable {
 
     pidPath = path.join(FileSystem.resources, "macos", "daemons", "cloudflare", "cloudflare.pid");
 
-    proc: ChildProcess;
+    proc: ProcessSpawner;
 
     currentProxyUrl: string;
 
@@ -37,21 +37,26 @@ export class CloudflareManager extends Loggable {
     }
 
     private async connectHandler(): Promise<string> {
-        return new Promise((resolve, reject) => {
+        return new Promise(async (resolve, reject) => {
             try {
                 const port = Server().repo.getConfig("socket_port") as string;
-                this.proc = spawn(this.daemonPath, [
-                    'tunnel',
-                    '--url', `localhost:${port}`,
-                    '--config', this.cfgPath,
-                    '--pidfile', this.pidPath
-                ]);
-
-                // Configure handlers for all the output events
-                this.proc.stdout.on("data", chunk => this.handleData(chunk));
-                this.proc.stdout.on("error", chunk => this.handleError(chunk));
-                this.proc.stderr.on("data", chunk => this.handleData(chunk));
-                this.proc.stderr.on("error", chunk => this.handleError(chunk));
+
+                this.log.debug("Starting Cloudflare Tunnel...");
+                this.proc = new ProcessSpawner({
+                    command: this.daemonPath,
+                    args: [
+                        'tunnel',
+                        '--url', `localhost:${port}`,
+                        '--config', this.cfgPath,
+                        '--pidfile', this.pidPath
+                    ],
+                    verbose: true,
+                    logTag: "CloudflareDaemon",
+                    onOutput: (data) => this.handleData(data),
+                    restartOnNonZeroExit: true,
+                    waitForExit: false,
+                    storeOutput: false
+                });
 
                 this.on("new-url", url => resolve(url));
                 this.on("error", err => {
@@ -66,6 +71,8 @@ export class CloudflareManager extends Loggable {
                 setTimeout(() => {
                     reject(new Error("Failed to connect to Cloudflare after 2 minutes..."));
                 }, 1000 * 60 * 2); // 2 minutes
+
+                await this.proc.execute();
             } catch (ex) {
                 reject(ex);
             }
@@ -102,6 +109,10 @@ export class CloudflareManager extends Loggable {
     private detectError(data: string): string | null {
         if (data.includes('no such host')) {
             return 'Unable to resolve api.trycloudflare.com! Ensure that your Mac has internet access and that any networking tools you use are not blocking the hostname.';
+        } else if (data.includes("context deadline exceeded")) {
+            return "Failed to connect to Cloudflare's servers! Connection timed out. Please check your internet connection and try again.";
+        } else if (data.includes("connect: bad file descriptor")) {
+            return "Failed to connect to Cloudflare's servers! Please make sure your Mac is up to date";
         } else if (data.includes('failed to request quick Tunnel: ')) {
             return data.split('failed to request quick Tunnel: ')[1];
         }
@@ -139,11 +150,6 @@ export class CloudflareManager extends Loggable {
         }
     }
 
-    handleError(chunk: any) {
-        const error = this.detectError(chunk);
-        if (error) this.emitError(error);
-    }
-
     emitError(err: any) {
         this.emit("error", err);
     }
diff --git a/packages/server/src/server/managers/zrokManager/index.ts b/packages/server/src/server/managers/zrokManager/index.ts
index 10dd4e92..3ed36090 100644
--- a/packages/server/src/server/managers/zrokManager/index.ts
+++ b/packages/server/src/server/managers/zrokManager/index.ts
@@ -1,13 +1,12 @@
 import { isNotEmpty, isEmpty } from "@server/helpers/utils";
 import { Loggable, getLogger } from "@server/lib/logging/Loggable";
 import { FileSystem } from "@server/fileSystem";
-import * as zx from "zx";
 import axios from "axios";
 import { app } from "electron";
 import { Server } from "@server";
-import { ProcessOutput } from "zx";
-import { spawn, ChildProcessWithoutNullStreams } from "child_process";
+import { spawn, ChildProcess } from "child_process";
 import path from "path";
+import { ProcessSpawner, ProcessSpawnerError } from "@server/lib/ProcessSpawner";
 
 export class ZrokManager extends Loggable {
     tag = "ZrokManager";
@@ -16,7 +15,7 @@ export class ZrokManager extends Loggable {
         return path.join(FileSystem.resources, "macos", "daemons", "zrok", (process.arch === "arm64") ? "arm64" : "x86", "zrok");
     }
 
-    proc: ChildProcessWithoutNullStreams;
+    proc: ChildProcess;
 
     currentProxyUrl: string;
 
@@ -140,24 +139,18 @@ export class ZrokManager extends Loggable {
         }
     }
 
-    static async setToken(token: string): Promise<ProcessOutput> {
+    static async setToken(token: string): Promise<string> {
         try {
-            return await zx.$`${this.daemonPath} enable ${token}`;
-        } catch (ex: any | ProcessOutput) {
-            if (ex.stderr) {
-                if (ex.stderr.includes("you already have an enabled environment")) {
-                    return ex;
-                }
-
-                throw new Error(ex.stderr.trim());
-            }
-
-            const err = ex.stdout;
-            if (err.includes("enableUnauthorized")) {
+            return await ProcessSpawner.executeCommand(this.daemonPath, ["enable", token], {}, "ZrokManager");
+        } catch (ex: any | ProcessSpawnerError) {
+            const output = ex?.output ?? ex?.message ?? String(ex);
+            if (output.includes("you already have an enabled environment")) {
+                return output;
+            } else if (output.includes("enableUnauthorized")) {
                 throw new Error("Invalid Zrok token!");
             } else {
                 const logger = getLogger("ZrokManager");
-                logger.error(`Failed to set Zrok token! Error: ${err}`);
+                logger.error(`Failed to set Zrok token! Error: ${output}`);
                 throw new Error("Failed to set Zrok token! Please check your server logs for more information.");
             }
         }
@@ -194,8 +187,7 @@ export class ZrokManager extends Loggable {
                 flags.push(name);
             }
 
-            const result = await zx.$`${this.daemonPath} reserve public ${flags}`;
-            const output = result.toString().trim();
+            const output = await ProcessSpawner.executeCommand(this.daemonPath, ["reserve", "public", ...flags], {}, "ZrokManager");
             const urlMatches = output.match(ZrokManager.proxyUrlRegex);
             if (isEmpty(urlMatches)) {
                 logger.debug(`Failed to reserve Zrok tunnel! Unable to find URL in output. Output: ${output}`);
@@ -217,34 +209,25 @@ export class ZrokManager extends Loggable {
 
             await Server().repo.setConfig("zrok_reserved_token", token);
             return token;
-        } catch (ex: any | ProcessOutput) {
-            if (ex.stderr) {
-                throw new Error(ex.stderr.trim());
-            }
-
-            const err = ex.stdout;
-            if (err) {
-                const logger = getLogger("ZrokManager");
-                if (err.includes("shareInternalServerError")) {
-                    throw new Error("Failed to reserve Zrok share! Internal server error! Share may be in use.");
-                } else {
-                    logger.error(`Failed to set Zrok token! Error: ${err}`);
-                    throw new Error("Failed to set Zrok token! Please check your server logs for more information.");
-                }
+        } catch (ex: any | ProcessSpawnerError) {
+            const output = ex?.output ?? ex?.message ?? String(ex);
+            const logger = getLogger("ZrokManager");
+            if (output.includes("shareInternalServerError")) {
+                throw new Error("Failed to reserve Zrok share! Internal server error! Share may be in use.");
+            } else {
+                logger.error(`Failed to set Zrok token! Error: ${output}`);
+                throw new Error("Failed to set Zrok token! Please check your server logs for more information.");
             }
-
-            throw ex;
         }
     }
 
-    static async disable(): Promise<ProcessOutput> {
-        return await zx.$`${this.daemonPath} disable`;
+    static async disable(): Promise<string> {
+        return await ProcessSpawner.executeCommand(this.daemonPath, ["disable"], {}, "ZrokManager");
     }
 
     static async getExistingReservedShareToken(name?: string): Promise<string | null> {
         // Run the overview command and parse the output
-        const result = await zx.$`${this.daemonPath} overview`;
-        const output = result.toString().trim();
+        const output = await ProcessSpawner.executeCommand(this.daemonPath, ["overview"], {}, "ZrokManager");
         const json = JSON.parse(output);
         const host = Server().computerIdentifier;
 
@@ -268,25 +251,21 @@ export class ZrokManager extends Loggable {
         return reserved?.token ?? null;
     }
 
-    static async release(token: string): Promise<ProcessOutput> {
+    static async release(token: string): Promise<string> {
         try {
-            const result = await zx.$`${this.daemonPath} release ${token}`;
+            const result = await ProcessSpawner.executeCommand(this.daemonPath, ["release", token], {}, "ZrokManager");
 
             // Clear the token from the config
             await Server().repo.setConfig("zrok_reserved_token", "");
             return result;
-        } catch (ex: any | ProcessOutput) {
-            if (ex.stderr) {
-                if (ex.stderr.includes("unshareNotFound")) {
-                    return ex;
-                }
-
-                throw new Error(ex.stderr.trim());
+        } catch (ex: any | ProcessSpawnerError) {
+            const output = ex?.output ?? ex?.message ?? String(ex);
+            const logger = getLogger("ZrokManager");
+            if (output.includes("unshareNotFound")) {
+                return output;
             }
 
-            const err = ex.stdout ?? "Unknown";
-            const logger = getLogger("ZrokManager");
-            logger.error(`Failed to release Zrok share! Error: ${err}`);
+            logger.error(`Failed to release Zrok share! Error: ${output}`);
             throw new Error("Failed to release Zrok share! Please check your server logs for more information.");
         }
     }

From 1720a2050f903987c31736c6b809941e6fda0568 Mon Sep 17 00:00:00 2001
From: zlshames <zlshames@gmail.com>
Date: Tue, 9 Jul 2024 11:21:38 -0400
Subject: [PATCH 11/11] fix: process spawner escaping

---
 packages/server/src/server/lib/ProcessSpawner.ts | 14 ++------------
 1 file changed, 2 insertions(+), 12 deletions(-)

diff --git a/packages/server/src/server/lib/ProcessSpawner.ts b/packages/server/src/server/lib/ProcessSpawner.ts
index 870b8b53..dc5e8dd2 100644
--- a/packages/server/src/server/lib/ProcessSpawner.ts
+++ b/packages/server/src/server/lib/ProcessSpawner.ts
@@ -205,7 +205,7 @@ export class ProcessSpawner extends Loggable {
                 const args = commandParts.slice(1);
 
                 // Spawn the process and pipe the output to the next process
-                const proc = spawn(program, this.quoteArgs(args), {
+                const proc = spawn(program, args, {
                     stdio: [
                         // If there is a previous process, pipe the output to the next process
                         (lastProcess) ? lastProcess.stdout : "pipe",
@@ -220,7 +220,7 @@ export class ProcessSpawner extends Loggable {
             return lastProcess;
         }
 
-        return spawn(this.command, this.quoteArgs(this.args), this.options);
+        return spawn(this.command, this.args, this.options);
     }
 
     private handleLog(log: string) {
@@ -258,16 +258,6 @@ export class ProcessSpawner extends Loggable {
         }
     }
 
-    private quoteArgs(args: string[]): string[] {
-        return args.map(arg => {
-            if (arg.includes(" ")) {
-                return `"${arg.replace(/"/g, '\\"')}"`;
-            } else {
-                return arg;
-            }
-        });
-    }
-
     static async executeCommand(
         command: string,
         args: string[] = [],