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