diff --git a/.github/ExportOptions.plist b/.github/ExportOptions.plist
new file mode 100644
index 0000000..6ab5c82
--- /dev/null
+++ b/.github/ExportOptions.plist
@@ -0,0 +1,12 @@
+
+
+
+
+ method
+ developer-id
+ teamID
+ 5HR8E5CWR7
+ signingStyle
+ automatic
+
+
diff --git a/.github/scripts/notarize-macos-zip.sh b/.github/scripts/notarize-macos-zip.sh
new file mode 100755
index 0000000..7cf2937
--- /dev/null
+++ b/.github/scripts/notarize-macos-zip.sh
@@ -0,0 +1,54 @@
+#!/bin/bash
+# Submit a zip of Jared.app for notarization and staple the app bundle.
+set -euo pipefail
+
+APP="${1:?Usage: notarize-macos-zip.sh path/to/Jared.app}"
+PROFILE="${NOTARY_KEYCHAIN_PROFILE:-notarytool}"
+
+if [ ! -d "$APP" ]; then
+ echo "Not a bundle: $APP" >&2
+ exit 1
+fi
+
+for var in APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do
+ if [ -z "${!var:-}" ]; then
+ echo "Missing $var — skipping notarization." >&2
+ exit 0
+ fi
+done
+
+if ! xcrun notarytool history --keychain-profile "$PROFILE" &>/dev/null; then
+ echo "Storing notary credentials in keychain profile: $PROFILE"
+ xcrun notarytool store-credentials "$PROFILE" \
+ --apple-id "$APPLE_ID" \
+ --team-id "$APPLE_TEAM_ID" \
+ --password "$APPLE_APP_SPECIFIC_PASSWORD"
+fi
+
+submit_zip="$(mktemp -t jared-notarize).zip"
+trap 'rm -f "$submit_zip"' EXIT
+
+ditto -c -k --keepParent "$APP" "$submit_zip"
+
+echo "Submitting $submit_zip for notarization..."
+submit_output="$(mktemp)"
+if ! xcrun notarytool submit "$submit_zip" --keychain-profile "$PROFILE" --wait 2>&1 | tee "$submit_output"; then
+ echo "notarytool submit failed." >&2
+ exit 1
+fi
+
+if grep -q 'status: Invalid' "$submit_output"; then
+ submission_id="$(sed -n 's/^[[:space:]]*id:[[:space:]]*//p' "$submit_output" | head -1)"
+ echo "Notarization rejected (Invalid)." >&2
+ if [ -n "$submission_id" ]; then
+ xcrun notarytool log "$submission_id" --keychain-profile "$PROFILE" || true
+ fi
+ exit 1
+fi
+rm -f "$submit_output"
+
+echo "Stapling notarization ticket to $APP..."
+xcrun stapler staple "$APP"
+xcrun stapler validate "$APP"
+
+echo "Notarization complete: $APP"
diff --git a/.github/scripts/release-macos.sh b/.github/scripts/release-macos.sh
new file mode 100755
index 0000000..a66996e
--- /dev/null
+++ b/.github/scripts/release-macos.sh
@@ -0,0 +1,60 @@
+#!/bin/bash
+
+set -euxo pipefail
+
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+cd "$ROOT"
+
+BUILD_DIR="$ROOT/build"
+ARCHIVE_PATH="$BUILD_DIR/Jared.xcarchive"
+EXPORT_PATH="$BUILD_DIR/export"
+EXPORT_OPTIONS="$ROOT/.github/ExportOptions.plist"
+
+if [[ -n "${GITHUB_REF_NAME:-}" && "$GITHUB_REF_NAME" == v* ]]; then
+ VERSION="${GITHUB_REF_NAME#v}"
+else
+ VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' JaredUI/Info.plist)"
+fi
+
+ARTIFACT_FILENAME="Jared-${VERSION}.zip"
+
+rm -rf "$BUILD_DIR"
+mkdir -p "$BUILD_DIR"
+
+archive_args=(
+ -project Jared.xcodeproj
+ -scheme JaredUI
+ -configuration Release
+ -archivePath "$ARCHIVE_PATH"
+ archive
+ DEVELOPMENT_TEAM=5HR8E5CWR7
+)
+
+if [ -n "${MACOS_CODESIGN_IDENTITY:-}" ]; then
+ archive_args+=(
+ CODE_SIGN_IDENTITY="$MACOS_CODESIGN_IDENTITY"
+ CODE_SIGN_STYLE=Manual
+ )
+else
+ archive_args+=(CODE_SIGN_STYLE=Automatic)
+fi
+
+xcodebuild "${archive_args[@]}"
+
+xcodebuild -exportArchive \
+ -archivePath "$ARCHIVE_PATH" \
+ -exportPath "$EXPORT_PATH" \
+ -exportOptionsPlist "$EXPORT_OPTIONS"
+
+APP_PATH="$EXPORT_PATH/Jared.app"
+if [ ! -d "$APP_PATH" ]; then
+ echo "Expected exported app at $APP_PATH" >&2
+ exit 1
+fi
+
+bash "$ROOT/.github/scripts/notarize-macos-zip.sh" "$APP_PATH"
+
+ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ARTIFACT_FILENAME"
+
+ls -la "$ARTIFACT_FILENAME"
+du -hs "$APP_PATH" "$ARTIFACT_FILENAME"
diff --git a/.github/scripts/test-macos.sh b/.github/scripts/test-macos.sh
new file mode 100755
index 0000000..f366607
--- /dev/null
+++ b/.github/scripts/test-macos.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+
+set -euxo pipefail
+
+xcodebuild -project Jared.xcodeproj -scheme JaredFramework build \
+ ONLY_ACTIVE_ARCH=NO \
+ CODE_SIGN_IDENTITY="" \
+ CODE_SIGNING_REQUIRED=NO
+
+xcodebuild -project Jared.xcodeproj -scheme JaredTests test \
+ ONLY_ACTIVE_ARCH=NO \
+ CODE_SIGN_IDENTITY="" \
+ CODE_SIGNING_REQUIRED=NO
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a163637..14ce57f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -13,9 +13,13 @@ concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
+permissions:
+ contents: read
+
jobs:
test:
name: JaredTests
+ if: vars.MACOS_CI_ENABLED == 'true'
runs-on:
- self-hosted
- macOS
@@ -30,11 +34,4 @@ jobs:
xcodebuild -version
- name: Run unit tests
- run: |
- xcodebuild \
- -project Jared.xcodeproj \
- -scheme JaredTests \
- test \
- ONLY_ACTIVE_ARCH=NO \
- CODE_SIGN_IDENTITY="" \
- CODE_SIGNING_REQUIRED=NO
+ run: bash ./.github/scripts/test-macos.sh
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..8a63950
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,63 @@
+name: release
+
+on:
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+jobs:
+ build-macos:
+ if: vars.MACOS_CI_ENABLED == 'true' || github.event_name == 'workflow_dispatch'
+ runs-on:
+ - self-hosted
+ - macOS
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Build release
+ env:
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ MACOS_CODESIGN_IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }}
+ run: bash ./.github/scripts/release-macos.sh
+
+ - name: Upload Jared.app zip
+ uses: actions/upload-artifact@v7
+ with:
+ name: jared-macos
+ path: Jared-*.zip
+ if-no-files-found: error
+
+ release:
+ name: Create release and upload artifacts
+ runs-on: ubuntu-latest
+ needs: [build-macos]
+ if: needs.build-macos.result == 'success'
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Download macOS artifact
+ uses: actions/download-artifact@v7
+ with:
+ name: jared-macos
+
+ - name: Display structure of downloaded files
+ run: ls -la
+
+ - name: Release
+ uses: softprops/action-gh-release@v3
+ with:
+ draft: true
+ prerelease: false
+ fail_on_unmatched_files: false
+ files: Jared-*.zip
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/AGENTS.md b/AGENTS.md
index 45bf255..0ce8b64 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,6 +13,17 @@
xcodebuild -project Jared.xcodeproj -scheme JaredTests test ONLY_ACTIVE_ARCH=NO CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO
```
+Or run the CI script (builds `JaredFramework` first):
+```
+bash ./.github/scripts/test-macos.sh
+```
+
+**GitHub Actions** (`.github/workflows/test.yml`): macOS unit tests run on a **self-hosted** runner only when the repository variable `MACOS_CI_ENABLED` is `true` (Settings → Secrets and variables → Actions → Variables). The runner must have labels `self-hosted` and `macOS`. When the variable is unset or not `true`, the job is skipped — flip it on when your Mac runner is online.
+
+**Release** (`.github/workflows/release.yml`): triggers on `v*` tags or **workflow_dispatch**. Builds on the same self-hosted Mac when `MACOS_CI_ENABLED` is `true` (always runs on manual dispatch). Script: `bash ./.github/scripts/release-macos.sh` (archive → export → optional notarize → zip). Uploads a draft GitHub release with `Jared-.zip`.
+
+Optional repository **secrets** for signing/notarization: `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`, `MACOS_CODESIGN_IDENTITY`. Notarization uses keychain profile `notarytool` (or creates it from those secrets). Without Apple secrets, export still runs if Developer ID is in the runner keychain; notarization is skipped.
+
**"No such module 'JaredFramework'" errors**: This is normal when Xcode hasn't built the framework target yet. Build the `JaredFramework` scheme first, then build `Jared`/`JaredTests`. These LSP errors are stale and not real compile failures.
## Architecture Overview
diff --git a/Jared.xcodeproj/project.pbxproj b/Jared.xcodeproj/project.pbxproj
index 9cfa8b6..b929bb2 100644
--- a/Jared.xcodeproj/project.pbxproj
+++ b/Jared.xcodeproj/project.pbxproj
@@ -24,6 +24,7 @@
B32DE51C24D552D300AB3A71 /* MockPluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32DE51B24D552D300AB3A71 /* MockPluginManager.swift */; };
B332787A1CB2CB1300BBDD3A /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B33278791CB2CB1300BBDD3A /* AppDelegate.swift */; };
EC000001000000000000AA0B /* MenuBarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AA0C /* MenuBarManager.swift */; };
+ EC000001000000000000AD01 /* SystemSleepPreventer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AD02 /* SystemSleepPreventer.swift */; };
B332787C1CB2CB1300BBDD3A /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B332787B1CB2CB1300BBDD3A /* ViewController.swift */; };
B332787E1CB2CB1300BBDD3A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B332787D1CB2CB1300BBDD3A /* Assets.xcassets */; };
B33278811CB2CB1300BBDD3A /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B332787F1CB2CB1300BBDD3A /* Main.storyboard */; };
@@ -197,6 +198,7 @@
B33278771CB2CB1300BBDD3A /* Jared.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Jared.app; sourceTree = BUILT_PRODUCTS_DIR; };
B33278791CB2CB1300BBDD3A /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
EC000001000000000000AA0C /* MenuBarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarManager.swift; sourceTree = ""; };
+ EC000001000000000000AD02 /* SystemSleepPreventer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemSleepPreventer.swift; sourceTree = ""; };
B332787B1CB2CB1300BBDD3A /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; };
B332787D1CB2CB1300BBDD3A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
B33278801CB2CB1300BBDD3A /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
@@ -362,6 +364,7 @@
children = (
B33278791CB2CB1300BBDD3A /* AppDelegate.swift */,
EC000001000000000000AA0C /* MenuBarManager.swift */,
+ EC000001000000000000AD02 /* SystemSleepPreventer.swift */,
B375A65821EB14470073F09F /* AboutViewController.swift */,
EC000001000000000000AC02 /* LLMSettingsViewController.swift */,
EC000001000000000000AC04 /* WebhookManagementWindowController.swift */,
@@ -800,6 +803,7 @@
B3B844CB24DA5D1700DC162A /* DiskAccessDelegate.swift in Sources */,
B332787A1CB2CB1300BBDD3A /* AppDelegate.swift in Sources */,
EC000001000000000000AA0B /* MenuBarManager.swift in Sources */,
+ EC000001000000000000AD01 /* SystemSleepPreventer.swift in Sources */,
B3BF17C4244EBF4900CC44C5 /* InternalModule.swift in Sources */,
B3BF17C2244EBE9A00CC44C5 /* Router.swift in Sources */,
B375A65921EB14470073F09F /* AboutViewController.swift in Sources */,
diff --git a/Jared/DatabaseHandler.swift b/Jared/DatabaseHandler.swift
index 4b6025a..c6f181f 100644
--- a/Jared/DatabaseHandler.swift
+++ b/Jared/DatabaseHandler.swift
@@ -37,7 +37,7 @@ class DatabaseHandler {
message.expressive_send_style_id,
message.associated_message_type,
message.associated_message_guid, message.guid, destination_caller_id,
- message.attributedBody
+ message.handle_id, message.attributedBody
FROM message LEFT JOIN handle
ON message.handle_id = handle.ROWID
WHERE message.ROWID > ? ORDER BY message.ROWID ASC
@@ -55,6 +55,8 @@ class DatabaseHandler {
private var walSource: DispatchSourceFileSystemObject?
private var walContinuation: AsyncStream.Continuation?
private var backgroundTask: Task?
+ /// Inbound rows deferred once while Messages backfills `handle_id` / handle join.
+ private var deferredIncomingRowIDs = Set()
/// Triggers an immediate poll, bypassing the refresh interval. Used in tests.
func triggerImmediateQuery() {
@@ -205,8 +207,10 @@ class DatabaseHandler {
var People = [Person]()
var groupName: String?
var chatGUID: String?
+ var foundRow = false
while sqlite3_step(statement) == SQLITE_ROW {
+ foundRow = true
guard let idcString = sqlite3_column_text(statement, 0) else {
break
}
@@ -219,6 +223,10 @@ class DatabaseHandler {
People.append(Person(givenName: givenName, handle: handle, isMe: false))
}
+ guard foundRow else {
+ return nil
+ }
+
return Group(name: groupName, handle: chatGUID ?? chatHandle, participants: People)
}
@@ -298,7 +306,7 @@ class DatabaseHandler {
while sqlite3_step(statement) == SQLITE_ROW {
var senderHandleOptional = unwrapStringColumn(for: statement, at: 0)
let rawText = unwrapStringColumn(for: statement, at: 1)
- let textOptional = (rawText?.isEmpty == false ? rawText : nil) ?? extractAttributedBodyText(for: statement, at: 13)
+ let textOptional = (rawText?.isEmpty == false ? rawText : nil) ?? extractAttributedBodyText(for: statement, at: 14)
let rowID = unwrapStringColumn(for: statement, at: 2)
let roomName = unwrapStringColumn(for: statement, at: 3)
let isFromMe = sqlite3_column_int(statement, 4) == 1
@@ -310,17 +318,25 @@ class DatabaseHandler {
let associatedMessageGUID = unwrapStringColumn(for: statement, at: 10)
let guid = unwrapStringColumn(for: statement, at: 11)
let destinationCallerId = unwrapStringColumn(for: statement, at: 12)
-
- querySinceID = rowID;
+ let messageHandleId = sqlite3_column_int(statement, 13)
- guard !isFromMe else { continue }
-
- if senderHandleOptional == nil {
- let raw = destinationCallerId ?? destinationOptional
- senderHandleOptional = raw?.hasPrefix("mailto:") == true ? String(raw!.dropFirst(7)) : raw
+ if !isFromMe, senderHandleOptional == nil {
+ if messageHandleId > 0 {
+ // handle_id points at a missing row — never substitute destination_caller_id.
+ querySinceID = rowID
+ continue
+ }
+ // handle_id still 0: Messages may not have linked the sender yet. Defer one poll
+ // so we don't treat a remote message as self-chat (destination_caller_id as sender).
+ if let id = rowID, !deferredIncomingRowIDs.contains(id) {
+ deferredIncomingRowIDs.insert(id)
+ break
+ }
+ senderHandleOptional = destinationCallerId ?? destinationOptional
}
-
+
guard let senderHandle = senderHandleOptional, let text = textOptional, let destination = destinationOptional else {
+ querySinceID = rowID
continue
}
@@ -344,6 +360,10 @@ class DatabaseHandler {
sendStyle: sendStyle, associatedMessageType: Int(associatedMessageType), associatedMessageGUID: associatedMessageGUID)
router?.route(message: message)
+ querySinceID = rowID
+ if let rowID {
+ deferredIncomingRowIDs.remove(rowID)
+ }
}
if sqlite3_finalize(statement) != SQLITE_OK {
diff --git a/Jared/JaredConstants.swift b/Jared/JaredConstants.swift
index 14b7ecd..201f524 100644
--- a/Jared/JaredConstants.swift
+++ b/Jared/JaredConstants.swift
@@ -16,6 +16,7 @@ struct JaredConstants {
static let sendMessageAccess = "SendMessageAccess"
static let fullDiskAccess = "FullDiskAccess"
static let webhookCount = "WebhookCount"
+ static let preventSystemSleep = "PreventSystemSleep"
static let fullDiskAcccessUrl = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles"
static let contactsAccessUrl = "x-apple.systempreferences:com.apple.preference.security?Privacy_Contacts"
static let automationAccessUrl = "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation"
diff --git a/Jared/Router.swift b/Jared/Router.swift
index 17ae925..e32bca6 100644
--- a/Jared/Router.swift
+++ b/Jared/Router.swift
@@ -22,6 +22,10 @@ class Router : RouterDelegate {
func route(message myMessage: Message) {
messageDelegates.forEach { delegate in delegate.didProcess(message: myMessage) }
+ if let sender = myMessage.sender as? Person, sender.isMe {
+ return
+ }
+
guard myMessage.body is TextBody || myMessage.action != nil else { return }
let defaults = UserDefaults.standard
diff --git a/JaredTests/DatabaseHandlerTest.swift b/JaredTests/DatabaseHandlerTest.swift
index 337a638..8fc0c12 100644
--- a/JaredTests/DatabaseHandlerTest.swift
+++ b/JaredTests/DatabaseHandlerTest.swift
@@ -9,6 +9,7 @@
import XCTest
@testable import Jared
import JaredFramework
+import SQLite3
private final class DiskAccessDelegateSpy: DiskAccessDelegate {
private(set) var didDisplayAccessError = false
@@ -83,6 +84,276 @@ class DatabaseHandlerTest: XCTestCase {
XCTAssertEqual(localRouter.messages.count, 2, "Both messages routed")
}
+ func testIncomingMessageWithMissingHandleDoesNotUseDestinationCallerIdAsSender() throws {
+ let tempDirectory = try makeTemporaryDatabaseDirectory()
+ let tempDatabaseURL = tempDirectory.appendingPathComponent("scaffold.db")
+ try FileManager.default.copyItem(at: testDatabaseLocation, to: tempDatabaseURL)
+ try Self.enableWAL(at: tempDatabaseURL)
+
+ let localRouter = MockRouter()
+ var handler: DatabaseHandler? = DatabaseHandler(
+ router: localRouter,
+ databaseLocation: tempDatabaseURL,
+ diskAccessDelegate: nil,
+ enableBackgroundPolling: false,
+ contactNameResolver: { _ in nil }
+ )
+ let localHelper = DatabaseTestHelper(databaseLocation: tempDatabaseURL)
+ defer {
+ handler = nil
+ try? FileManager.default.removeItem(at: tempDirectory)
+ }
+
+ let localPhone = "+18473125379"
+ let chatID = localHelper.insertChat(accountId: localPhone, service: "iMessage")
+ let timestamp = currentTimestamp()
+ let messageID = localHelper.insertMessage(
+ guid: "missing-handle",
+ messageText: "Cool",
+ handleID: 99999,
+ service: "iMessage",
+ account: localPhone,
+ accountGuid: "account-guid",
+ date: timestamp,
+ dateRead: nil,
+ dateDelivered: nil,
+ isFromMe: false,
+ hasAttachments: false,
+ destinationCallerID: localPhone
+ )
+ localHelper.linkChatAndMessage(chatID: chatID, messageID: messageID, date: timestamp)
+
+ _ = handler?.queryNewRecords()
+
+ XCTAssertEqual(localRouter.messages.count, 0, "Incoming message without sender handle should be skipped")
+ }
+
+ func testIncomingOneToOneMessageUsesRemoteHandleNotLocalDestination() throws {
+ let tempDirectory = try makeTemporaryDatabaseDirectory()
+ let tempDatabaseURL = tempDirectory.appendingPathComponent("scaffold.db")
+ try FileManager.default.copyItem(at: testDatabaseLocation, to: tempDatabaseURL)
+ try Self.enableWAL(at: tempDatabaseURL)
+
+ let localRouter = MockRouter()
+ var handler: DatabaseHandler? = DatabaseHandler(
+ router: localRouter,
+ databaseLocation: tempDatabaseURL,
+ diskAccessDelegate: nil,
+ enableBackgroundPolling: false,
+ contactNameResolver: { _ in nil }
+ )
+ let localHelper = DatabaseTestHelper(databaseLocation: tempDatabaseURL)
+ defer {
+ handler = nil
+ try? FileManager.default.removeItem(at: tempDirectory)
+ }
+
+ let localPhone = "+18473125379"
+ let remotePhone = "+18606558466"
+ let remoteHandleID = localHelper.insertHandle(id: remotePhone, service: "iMessage")
+ let chatID = localHelper.insertChat(accountId: localPhone, service: "iMessage")
+ localHelper.linkChatAndHandle(chatID: chatID, handleID: remoteHandleID)
+
+ let timestamp = currentTimestamp()
+ let messageID = localHelper.insertMessage(
+ guid: "27EAF9AF-2134-431A-A3E7-BC758959DD88",
+ messageText: "Cool",
+ handleID: remoteHandleID,
+ service: "iMessage",
+ account: localPhone,
+ accountGuid: "account-guid",
+ date: timestamp,
+ dateRead: nil,
+ dateDelivered: nil,
+ isFromMe: false,
+ hasAttachments: false,
+ destinationCallerID: localPhone,
+ cacheRoomNamesNull: true
+ )
+ localHelper.linkChatAndMessage(chatID: chatID, messageID: messageID, date: timestamp)
+
+ _ = handler?.queryNewRecords()
+
+ XCTAssertEqual(localRouter.messages.count, 1)
+ let message = try XCTUnwrap(localRouter.messages.first)
+ let sender = try XCTUnwrap(message.sender as? Person)
+ let recipient = try XCTUnwrap(message.recipient as? Person)
+ XCTAssertEqual(sender.handle, remotePhone)
+ XCTAssertFalse(sender.isMe)
+ XCTAssertEqual(recipient.handle, localPhone)
+ XCTAssertTrue(recipient.isMe)
+ XCTAssertNotEqual(sender.handle, recipient.handle)
+ }
+
+ func testIncomingMessageDefersUntilHandleIdIsLinked() throws {
+ let tempDirectory = try makeTemporaryDatabaseDirectory()
+ let tempDatabaseURL = tempDirectory.appendingPathComponent("scaffold.db")
+ try FileManager.default.copyItem(at: testDatabaseLocation, to: tempDatabaseURL)
+ try Self.enableWAL(at: tempDatabaseURL)
+
+ let localRouter = MockRouter()
+ var handler: DatabaseHandler? = DatabaseHandler(
+ router: localRouter,
+ databaseLocation: tempDatabaseURL,
+ diskAccessDelegate: nil,
+ enableBackgroundPolling: false,
+ contactNameResolver: { _ in nil }
+ )
+ let localHelper = DatabaseTestHelper(databaseLocation: tempDatabaseURL)
+ defer {
+ handler = nil
+ try? FileManager.default.removeItem(at: tempDirectory)
+ }
+
+ let localPhone = "+18473125379"
+ let remotePhone = "+18606558466"
+ let remoteHandleID = localHelper.insertHandle(id: remotePhone, service: "iMessage")
+ let chatID = localHelper.insertChat(accountId: localPhone, service: "iMessage")
+ localHelper.linkChatAndHandle(chatID: chatID, handleID: remoteHandleID)
+
+ let timestamp = currentTimestamp()
+ let messageID = localHelper.insertMessage(
+ guid: "deferred-handle-link",
+ messageText: "Hello",
+ handleID: 0,
+ service: "iMessage",
+ account: localPhone,
+ accountGuid: "account-guid",
+ date: timestamp,
+ dateRead: nil,
+ dateDelivered: nil,
+ isFromMe: false,
+ hasAttachments: false,
+ destinationCallerID: localPhone,
+ cacheRoomNamesNull: true
+ )
+ localHelper.linkChatAndMessage(chatID: chatID, messageID: messageID, date: timestamp)
+
+ _ = handler?.queryNewRecords()
+ XCTAssertEqual(localRouter.messages.count, 0, "Should defer instead of using destination_caller_id as sender")
+
+ localHelper.updateMessageHandleId(messageID: messageID, handleID: remoteHandleID)
+
+ _ = handler?.queryNewRecords()
+
+ XCTAssertEqual(localRouter.messages.count, 1)
+ let message = try XCTUnwrap(localRouter.messages.first)
+ let sender = try XCTUnwrap(message.sender as? Person)
+ let recipient = try XCTUnwrap(message.recipient as? Person)
+ XCTAssertEqual(sender.handle, remotePhone)
+ XCTAssertFalse(sender.isMe)
+ XCTAssertEqual(recipient.handle, localPhone)
+ XCTAssertTrue(recipient.isMe)
+ }
+
+ func testSelfChatMessageWithNullHandleRoutesUsingDestinationCallerId() throws {
+ let tempDirectory = try makeTemporaryDatabaseDirectory()
+ let tempDatabaseURL = tempDirectory.appendingPathComponent("scaffold.db")
+ try FileManager.default.copyItem(at: testDatabaseLocation, to: tempDatabaseURL)
+ try Self.enableWAL(at: tempDatabaseURL)
+
+ let localRouter = MockRouter()
+ var handler: DatabaseHandler? = DatabaseHandler(
+ router: localRouter,
+ databaseLocation: tempDatabaseURL,
+ diskAccessDelegate: nil,
+ enableBackgroundPolling: false,
+ contactNameResolver: { _ in nil }
+ )
+ let localHelper = DatabaseTestHelper(databaseLocation: tempDatabaseURL)
+ defer {
+ handler = nil
+ try? FileManager.default.removeItem(at: tempDirectory)
+ }
+
+ let localPhone = "+18473125379"
+ let chatID = localHelper.insertChat(accountId: localPhone, service: "iMessage")
+ let timestamp = currentTimestamp()
+ let messageID = localHelper.insertMessage(
+ guid: "self-chat-null-handle",
+ messageText: "note to self",
+ handleID: 0,
+ service: "iMessage",
+ account: localPhone,
+ accountGuid: "account-guid",
+ date: timestamp,
+ dateRead: nil,
+ dateDelivered: nil,
+ isFromMe: false,
+ hasAttachments: false,
+ destinationCallerID: localPhone,
+ cacheRoomNamesNull: true,
+ nullHandleID: true
+ )
+ localHelper.linkChatAndMessage(chatID: chatID, messageID: messageID, date: timestamp)
+
+ _ = handler?.queryNewRecords()
+ XCTAssertEqual(localRouter.messages.count, 0, "Self-chat defers one poll while handle_id is unset")
+
+ _ = handler?.queryNewRecords()
+
+ XCTAssertEqual(localRouter.messages.count, 1)
+ let message = try XCTUnwrap(localRouter.messages.first)
+ let sender = try XCTUnwrap(message.sender as? Person)
+ let recipient = try XCTUnwrap(message.recipient as? Person)
+ XCTAssertEqual(sender.handle, localPhone)
+ XCTAssertEqual(recipient.handle, localPhone)
+ }
+
+ func testOutgoingMessageRoutesWithSenderIsMe() throws {
+ let tempDirectory = try makeTemporaryDatabaseDirectory()
+ let tempDatabaseURL = tempDirectory.appendingPathComponent("scaffold.db")
+ try FileManager.default.copyItem(at: testDatabaseLocation, to: tempDatabaseURL)
+ try Self.enableWAL(at: tempDatabaseURL)
+
+ let localRouter = MockRouter()
+ var handler: DatabaseHandler? = DatabaseHandler(
+ router: localRouter,
+ databaseLocation: tempDatabaseURL,
+ diskAccessDelegate: nil,
+ enableBackgroundPolling: false,
+ contactNameResolver: { _ in nil }
+ )
+ let localHelper = DatabaseTestHelper(databaseLocation: tempDatabaseURL)
+ defer {
+ handler = nil
+ try? FileManager.default.removeItem(at: tempDirectory)
+ }
+
+ let localPhone = "+18473125379"
+ let remotePhone = "+18606558466"
+ let remoteHandleID = localHelper.insertHandle(id: remotePhone, service: "iMessage")
+ let chatID = localHelper.insertChat(accountId: localPhone, service: "iMessage")
+ localHelper.linkChatAndHandle(chatID: chatID, handleID: remoteHandleID)
+ let timestamp = currentTimestamp()
+ let messageID = localHelper.insertMessage(
+ guid: "outbound-message",
+ messageText: "sent from mac",
+ handleID: remoteHandleID,
+ service: "iMessage",
+ account: localPhone,
+ accountGuid: "account-guid",
+ date: timestamp,
+ dateRead: nil,
+ dateDelivered: nil,
+ isFromMe: true,
+ hasAttachments: false,
+ destinationCallerID: localPhone
+ )
+ localHelper.linkChatAndMessage(chatID: chatID, messageID: messageID, date: timestamp)
+
+ _ = handler?.queryNewRecords()
+
+ XCTAssertEqual(localRouter.messages.count, 1)
+ let message = try XCTUnwrap(localRouter.messages.first)
+ let sender = try XCTUnwrap(message.sender as? Person)
+ let recipient = try XCTUnwrap(message.recipient as? Person)
+ XCTAssertTrue(sender.isMe)
+ XCTAssertEqual(sender.handle, localPhone)
+ XCTAssertFalse(recipient.isMe)
+ XCTAssertEqual(recipient.handle, remotePhone)
+ }
+
func testInitDoesNotCreateMissingWALFile() throws {
let tempDirectory = try makeTemporaryDatabaseDirectory()
let tempDatabaseURL = tempDirectory.appendingPathComponent("scaffold.db")
@@ -136,4 +407,15 @@ class DatabaseHandlerTest: XCTestCase {
return directory
}
+ private static func enableWAL(at databaseURL: URL) throws {
+ var db: OpaquePointer?
+ guard sqlite3_open(databaseURL.path, &db) == SQLITE_OK else {
+ throw NSError(domain: "DatabaseHandlerTest", code: 1, userInfo: [NSLocalizedDescriptionKey: "Could not open test database"])
+ }
+ defer { sqlite3_close(db) }
+ guard sqlite3_exec(db, "PRAGMA journal_mode=WAL;", nil, nil, nil) == SQLITE_OK else {
+ throw NSError(domain: "DatabaseHandlerTest", code: 2, userInfo: [NSLocalizedDescriptionKey: "Could not enable WAL"])
+ }
+ }
+
}
diff --git a/JaredTests/DatabaseTestHelper.swift b/JaredTests/DatabaseTestHelper.swift
index 86c2328..55622b9 100644
--- a/JaredTests/DatabaseTestHelper.swift
+++ b/JaredTests/DatabaseTestHelper.swift
@@ -125,7 +125,7 @@ INSERT INTO "main"."chat_message_join" ("chat_id", "message_id", "message_date")
_ = sqlite3_step(statement)
}
- func insertMessage(guid: String, messageText: String, handleID: Int, service: String, account: String, accountGuid: String, date: Int?, dateRead: Int?, dateDelivered: Int?, isFromMe: Bool, hasAttachments: Bool, destinationCallerID: String, roomNames: String? = nil, associatedMessageGUID: String? = nil, sendStyleId: String? = nil, associatedMessageType: Int? = nil) -> Int {
+ func insertMessage(guid: String, messageText: String, handleID: Int, service: String, account: String, accountGuid: String, date: Int?, dateRead: Int?, dateDelivered: Int?, isFromMe: Bool, hasAttachments: Bool, destinationCallerID: String, roomNames: String? = nil, cacheRoomNamesNull: Bool = false, nullHandleID: Bool = false, associatedMessageGUID: String? = nil, sendStyleId: String? = nil, associatedMessageType: Int? = nil) -> Int {
var statement: OpaquePointer?
defer { sqlite3_finalize(statement) }
@@ -142,7 +142,12 @@ INSERT INTO "main"."chat_message_join" ("chat_id", "message_id", "message_date")
let errmsg = String(cString: sqlite3_errmsg(db)!)
print("failure binding foo: \(errmsg)")
}
- if sqlite3_bind_int(statement, 3, Int32(handleID)) != SQLITE_OK {
+ if nullHandleID {
+ if sqlite3_bind_null(statement, 3) != SQLITE_OK {
+ let errmsg = String(cString: sqlite3_errmsg(db)!)
+ print("failure binding handle_id null: \(errmsg)")
+ }
+ } else if sqlite3_bind_int(statement, 3, Int32(handleID)) != SQLITE_OK {
let errmsg = String(cString: sqlite3_errmsg(db)!)
print("failure binding foo: \(errmsg)")
}
@@ -178,7 +183,12 @@ INSERT INTO "main"."chat_message_join" ("chat_id", "message_id", "message_date")
let errmsg = String(cString: sqlite3_errmsg(db)!)
print("failure binding foo: \(errmsg)")
}
- if sqlite3_bind_text(statement, 12, roomNames ?? "", -1, SQLITE_TRANSIENT) != SQLITE_OK {
+ if cacheRoomNamesNull {
+ if sqlite3_bind_null(statement, 12) != SQLITE_OK {
+ let errmsg = String(cString: sqlite3_errmsg(db)!)
+ print("failure binding cache_roomnames null: \(errmsg)")
+ }
+ } else if sqlite3_bind_text(statement, 12, roomNames ?? "", -1, SQLITE_TRANSIENT) != SQLITE_OK {
let errmsg = String(cString: sqlite3_errmsg(db)!)
print("failure binding foo: \(errmsg)")
}
@@ -295,4 +305,14 @@ INSERT INTO "main"."chat_message_join" ("chat_id", "message_id", "message_date")
_ = sqlite3_step(statement)
}
+
+ func updateMessageHandleId(messageID: Int, handleID: Int) {
+ let query = "UPDATE message SET handle_id = ? WHERE ROWID = ?"
+ var statement: OpaquePointer?
+ guard sqlite3_prepare_v2(db, query, -1, &statement, nil) == SQLITE_OK else { return }
+ defer { sqlite3_finalize(statement) }
+ sqlite3_bind_int(statement, 1, Int32(handleID))
+ sqlite3_bind_int(statement, 2, Int32(messageID))
+ sqlite3_step(statement)
+ }
}
diff --git a/JaredTests/RouterTests.swift b/JaredTests/RouterTests.swift
index 770053f..2c61fe4 100644
--- a/JaredTests/RouterTests.swift
+++ b/JaredTests/RouterTests.swift
@@ -8,6 +8,15 @@
import XCTest
import JaredFramework
+@testable import Jared
+
+private final class MessageDelegateSpy: MessageDelegate {
+ private(set) var messages = [Message]()
+
+ func didProcess(message: Message) {
+ messages.append(message)
+ }
+}
class RouterTests: XCTestCase {
let mockPluginManager = MockPluginManager()
@@ -83,4 +92,21 @@ class RouterTests: XCTestCase {
router.route(message: doesNotContain)
XCTAssertEqual(mockPluginManager.callCounts[isString], 1, "Contains delegate was not called")
}
+
+ func testOutgoingMessageNotifiesDelegatesButSkipsPluginRoutes() {
+ let delegate = MessageDelegateSpy()
+ router = Router(pluginManager: mockPluginManager, messageDelegates: [delegate])
+ mockPluginManager.callCounts.removeAll()
+
+ let outgoing = Message(
+ body: TextBody("\(startWithString) from me"),
+ date: Date(),
+ sender: RouterTests.mePerson,
+ recipient: RouterTests.swiftPerson
+ )
+ router.route(message: outgoing)
+
+ XCTAssertEqual(delegate.messages.count, 1)
+ XCTAssertNil(mockPluginManager.callCounts[startWithString])
+ }
}
diff --git a/JaredUI/AppDelegate.swift b/JaredUI/AppDelegate.swift
index 518656b..555d8c5 100644
--- a/JaredUI/AppDelegate.swift
+++ b/JaredUI/AppDelegate.swift
@@ -22,7 +22,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
JaredConstants.jaredIsDisabled: false,
JaredConstants.restApiIsDisabled: true,
JaredConstants.contactsAccess: CNAuthorizationStatus.notDetermined.rawValue,
- JaredConstants.fullDiskAccess: true
+ JaredConstants.fullDiskAccess: true,
+ JaredConstants.preventSystemSleep: false
])
let config = ConfigurationHelper.getConfiguration()
@@ -61,6 +62,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let configURL = ConfigurationHelper.getSupportDirectory().appendingPathComponent("config.json")
pluginManager.startWatchingConfig(at: configURL)
+ SystemSleepPreventer.shared.start()
}
@objc private func tunnelConfigDidChange(_ notification: Notification) {
@@ -70,6 +72,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
func applicationWillTerminate(_ aNotification: Notification) {
+ SystemSleepPreventer.shared.stop()
tunnelManager.stop()
}
diff --git a/JaredUI/MenuBarManager.swift b/JaredUI/MenuBarManager.swift
index 6291bde..36cd193 100644
--- a/JaredUI/MenuBarManager.swift
+++ b/JaredUI/MenuBarManager.swift
@@ -52,6 +52,15 @@ class MenuBarManager {
toggleItem.target = self
menu.addItem(toggleItem)
+ let sleepItem = NSMenuItem(
+ title: "Keep Mac Awake",
+ action: #selector(togglePreventSleep),
+ keyEquivalent: ""
+ )
+ sleepItem.target = self
+ sleepItem.state = UserDefaults.standard.bool(forKey: JaredConstants.preventSystemSleep) ? .on : .off
+ menu.addItem(sleepItem)
+
let reloadItem = NSMenuItem(title: "Reload Plugins", action: #selector(reloadPlugins), keyEquivalent: "r")
reloadItem.target = self
menu.addItem(reloadItem)
@@ -75,6 +84,11 @@ class MenuBarManager {
UserDefaults.standard.set(!current, forKey: JaredConstants.jaredIsDisabled)
}
+ @objc private func togglePreventSleep() {
+ let current = UserDefaults.standard.bool(forKey: JaredConstants.preventSystemSleep)
+ UserDefaults.standard.set(!current, forKey: JaredConstants.preventSystemSleep)
+ }
+
@objc private func reloadPlugins() {
pluginManager.reload()
}
diff --git a/JaredUI/SystemSleepPreventer.swift b/JaredUI/SystemSleepPreventer.swift
new file mode 100644
index 0000000..f29cd5e
--- /dev/null
+++ b/JaredUI/SystemSleepPreventer.swift
@@ -0,0 +1,73 @@
+//
+// SystemSleepPreventer.swift
+// JaredUI
+//
+
+import Foundation
+import IOKit.pwr_mgt
+
+/// Holds an IOPM assertion that prevents idle system sleep while allowing the display to sleep.
+final class SystemSleepPreventer {
+ static let shared = SystemSleepPreventer()
+
+ private var assertionID: IOPMAssertionID = 0
+ private(set) var isActive = false
+
+ private init() {
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(defaultsChanged),
+ name: UserDefaults.didChangeNotification,
+ object: nil
+ )
+ }
+
+ deinit {
+ releaseAssertion()
+ }
+
+ func start() {
+ syncWithUserDefaults()
+ }
+
+ func stop() {
+ releaseAssertion()
+ }
+
+ @objc private func defaultsChanged() {
+ syncWithUserDefaults()
+ }
+
+ private func syncWithUserDefaults() {
+ if UserDefaults.standard.bool(forKey: JaredConstants.preventSystemSleep) {
+ acquireAssertion()
+ } else {
+ releaseAssertion()
+ }
+ }
+
+ private func acquireAssertion() {
+ guard !isActive else { return }
+
+ let reason = "Jared is preventing system sleep for iMessage polling" as CFString
+ let result = IOPMAssertionCreateWithName(
+ kIOPMAssertionTypePreventUserIdleSystemSleep as CFString,
+ IOPMAssertionLevel(kIOPMAssertionLevelOn),
+ reason,
+ &assertionID
+ )
+
+ if result == kIOReturnSuccess {
+ isActive = true
+ } else {
+ NSLog("SystemSleepPreventer: IOPMAssertionCreateWithName failed (%d)", result)
+ }
+ }
+
+ private func releaseAssertion() {
+ guard isActive else { return }
+ IOPMAssertionRelease(assertionID)
+ assertionID = 0
+ isActive = false
+ }
+}
diff --git a/JaredUI/ViewController.swift b/JaredUI/ViewController.swift
index 1224dac..5b3fc84 100644
--- a/JaredUI/ViewController.swift
+++ b/JaredUI/ViewController.swift
@@ -36,6 +36,7 @@ class ViewController: NSViewController, DiskAccessDelegate {
private var webhookRow: StatusRowView!
private var contactsRow: StatusRowView!
private var sendRow: StatusRowView!
+ private var sleepRow: StatusRowView!
private var tunnelRow: StatusRowView!
private var tunnelManagementWindowController: TunnelManagementWindowController?
@@ -51,6 +52,7 @@ class ViewController: NSViewController, DiskAccessDelegate {
JaredConstants.sendMessageAccess,
JaredConstants.fullDiskAccess,
JaredConstants.webhookCount,
+ JaredConstants.preventSystemSleep,
]
// MARK: - Lifecycle
@@ -168,6 +170,7 @@ class ViewController: NSViewController, DiskAccessDelegate {
webhookRow = StatusRowView(icon: "bolt.horizontal.fill", iconColor: .systemPink, title: "Webhooks")
contactsRow = StatusRowView(icon: "person.fill", iconColor: .systemOrange, title: "Contacts")
sendRow = StatusRowView(icon: "envelope.fill", iconColor: .systemPurple, title: "Messages automation")
+ sleepRow = StatusRowView(icon: "moon.zzz.fill", iconColor: .systemYellow, title: "Keep Mac awake")
apiRow.actionButton.target = self
apiRow.actionButton.action = #selector(EnableDisableRestApiAction(_:))
@@ -183,9 +186,11 @@ class ViewController: NSViewController, DiskAccessDelegate {
sendRow.actionButton.action = #selector(sendStatusButtonAction(_:))
diskRow.actionButton.target = self
diskRow.actionButton.action = #selector(openDiskPrefs)
+ sleepRow.actionButton.target = self
+ sleepRow.actionButton.action = #selector(togglePreventSleepAction(_:))
let rows: [NSView] = [
- sectionHeader("Status"), jaredRow, diskRow,
+ sectionHeader("Status"), jaredRow, diskRow, sleepRow,
separator(),
sectionHeader("Services"), apiRow, tunnelRow, llmRow, webhookRow,
separator(),
@@ -216,7 +221,7 @@ class ViewController: NSViewController, DiskAccessDelegate {
content.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor),
])
- for row in [jaredRow, diskRow, apiRow, tunnelRow, llmRow, webhookRow, contactsRow, sendRow] as [NSView] {
+ for row in [jaredRow, diskRow, sleepRow, apiRow, tunnelRow, llmRow, webhookRow, contactsRow, sendRow] as [NSView] {
row.leadingAnchor.constraint(equalTo: stack.leadingAnchor).isActive = true
row.trailingAnchor.constraint(equalTo: stack.trailingAnchor).isActive = true
}
@@ -337,6 +342,22 @@ class ViewController: NSViewController, DiskAccessDelegate {
self.diskRow?.update(statusText: "Granted", state: .on, buttonTitle: nil)
}
+ // Sleep row
+ let keepAwake = self.defaults.bool(forKey: JaredConstants.preventSystemSleep)
+ if keepAwake {
+ self.sleepRow?.update(
+ statusText: "System stays awake; display may sleep",
+ state: .on,
+ buttonTitle: "Disable"
+ )
+ } else {
+ self.sleepRow?.update(
+ statusText: "Mac can sleep when idle",
+ state: .off,
+ buttonTitle: "Enable"
+ )
+ }
+
// API row
if apiOff {
self.apiRow?.update(statusText: "Disabled", state: .off, buttonTitle: "Enable")
@@ -466,6 +487,10 @@ class ViewController: NSViewController, DiskAccessDelegate {
defaults.set(!defaults.bool(forKey: JaredConstants.restApiIsDisabled), forKey: JaredConstants.restApiIsDisabled)
}
+ @IBAction func togglePreventSleepAction(_ sender: Any) {
+ defaults.set(!defaults.bool(forKey: JaredConstants.preventSystemSleep), forKey: JaredConstants.preventSystemSleep)
+ }
+
@objc private func tunnelStateDidChange(_ notification: Notification) {
tunnelPublicURL = notification.userInfo?[TunnelManager.publicURLUserInfoKey] as? URL
tunnelError = notification.userInfo?[TunnelManager.lastErrorUserInfoKey] as? String