diff --git a/scripts/vphoned/vphoned.m b/scripts/vphoned/vphoned.m index c31049cc..286356d6 100644 --- a/scripts/vphoned/vphoned.m +++ b/scripts/vphoned/vphoned.m @@ -393,6 +393,14 @@ static BOOL handle_client(int fd) { continue; } + // Generic notify_post + if ([t isEqualToString:@"notify_post"]) { + NSDictionary *resp = vp_handle_notify_post_command(msg); + if (resp && !vp_write_message(fd, resp)) + break; + continue; + } + NSDictionary *resp = handle_command(msg); if (resp && !vp_write_message(fd, resp)) break; diff --git a/scripts/vphoned/vphoned_notify.h b/scripts/vphoned/vphoned_notify.h index 4fce53d4..1be0f097 100644 --- a/scripts/vphoned/vphoned_notify.h +++ b/scripts/vphoned/vphoned_notify.h @@ -2,3 +2,4 @@ #import NSDictionary *vp_handle_notify_command(NSDictionary *msg); +NSDictionary *vp_handle_notify_post_command(NSDictionary *msg); diff --git a/scripts/vphoned/vphoned_notify.m b/scripts/vphoned/vphoned_notify.m index 76223b1a..5e9ba33f 100644 --- a/scripts/vphoned/vphoned_notify.m +++ b/scripts/vphoned/vphoned_notify.m @@ -1,10 +1,9 @@ /* - * vphoned_notify — Low power mode state sync. + * vphoned_notify — Darwin notification helpers. * - * Sets LPM state via notify_set_state("com.apple.system.lowpowermode") + notify_post. - * All registrations for a notification name share one state value, so this - * updates what NSProcessInfo and SpringBoard read via notify_get_state — - * mirroring what powerd does internally when the user toggles Low Power Mode. + * Low power mode: Sets LPM state via notify_set_state + notify_post. + * Generic notify_post: Posts an arbitrary Darwin notification name, optionally + * setting a uint64 state value before posting. */ #import "vphoned_notify.h" @@ -30,3 +29,37 @@ r[@"ok"] = @(ok); return r; } + +NSDictionary *vp_handle_notify_post_command(NSDictionary *msg) { + id reqId = msg[@"id"]; + NSString *name = msg[@"name"]; + if (!name || name.length == 0) { + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = @"missing notification name"; + return r; + } + + const char *cname = [name UTF8String]; + BOOL hasState = (msg[@"state"] != nil); + BOOL ok = YES; + + if (hasState) { + uint64_t state = [msg[@"state"] unsignedLongLongValue]; + int token = 0; + ok = (notify_register_check(cname, &token) == NOTIFY_STATUS_OK); + if (ok) { + notify_set_state(token, state); + ok = (notify_post(cname) == NOTIFY_STATUS_OK); + notify_cancel(token); + } + NSLog(@"vphoned: notify_post %@ state=%llu -> %s", name, state, + ok ? "ok" : "failed"); + } else { + ok = (notify_post(cname) == NOTIFY_STATUS_OK); + NSLog(@"vphoned: notify_post %@ -> %s", name, ok ? "ok" : "failed"); + } + + NSMutableDictionary *r = vp_make_response(@"notify_post", reqId); + r[@"ok"] = @(ok); + return r; +} diff --git a/sources/vphone-cli/VPhoneAppDelegate.swift b/sources/vphone-cli/VPhoneAppDelegate.swift index 5dfd3095..0c86d187 100644 --- a/sources/vphone-cli/VPhoneAppDelegate.swift +++ b/sources/vphone-cli/VPhoneAppDelegate.swift @@ -144,6 +144,7 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate { mc?.updateURLAvailability(available: caps.contains("url")) mc?.updateClipboardAvailability(available: caps.contains("clipboard")) mc?.updateSettingsAvailability(available: true) + mc?.updateNotifyAvailability(available: true) if caps.contains("location") { mc?.updateLocationCapability(available: true) // Auto-resume if user had toggle on @@ -166,6 +167,7 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate { mc?.updateURLAvailability(available: false) mc?.updateClipboardAvailability(available: false) mc?.updateSettingsAvailability(available: false) + mc?.updateNotifyAvailability(available: false) provider?.stopReplay() provider?.stopForwarding() mc?.updateLocationCapability(available: false) diff --git a/sources/vphone-cli/VPhoneControl.swift b/sources/vphone-cli/VPhoneControl.swift index aa23fbf9..08f8343e 100644 --- a/sources/vphone-cli/VPhoneControl.swift +++ b/sources/vphone-cli/VPhoneControl.swift @@ -660,6 +660,18 @@ class VPhoneControl { } } + // MARK: - Notify Post + + func notifyPost(name: String, state: UInt64? = nil) async throws { + var req: [String: Any] = ["t": "notify_post", "name": name] + if let state { req["state"] = state } + let (resp, _) = try await sendRequest(req) + let ok = resp["ok"] as? Bool ?? false + if !ok { + throw ControlError.guestError("notify_post failed for \(name)") + } + } + // MARK: - Accessibility func accessibilityTree(depth: Int = -1) async throws -> [String: Any] { diff --git a/sources/vphone-cli/VPhoneMenuConnect.swift b/sources/vphone-cli/VPhoneMenuConnect.swift index 78d22b64..308be280 100644 --- a/sources/vphone-cli/VPhoneMenuConnect.swift +++ b/sources/vphone-cli/VPhoneMenuConnect.swift @@ -66,6 +66,13 @@ extension VPhoneMenuController { menu.addItem(buildLocationSubmenu()) menu.addItem(buildBatterySubmenu()) + menu.addItem(NSMenuItem.separator()) + + let notifyItem = makeItem("Send Darwin Notification...", action: #selector(postNotification)) + notifyItem.isEnabled = false + notifyPostItem = notifyItem + menu.addItem(notifyItem) + item.submenu = menu return item } diff --git a/sources/vphone-cli/VPhoneMenuController.swift b/sources/vphone-cli/VPhoneMenuController.swift index aa09e838..7429b752 100644 --- a/sources/vphone-cli/VPhoneMenuController.swift +++ b/sources/vphone-cli/VPhoneMenuController.swift @@ -43,6 +43,7 @@ class VPhoneMenuController { var powerSourceRunLoopSource: CFRunLoopSource? var powerSourceRetainedPtr: UnsafeMutableRawPointer? var lowPowerObserver: (any NSObjectProtocol)? + var notifyPostItem: NSMenuItem? init(keyHelper: VPhoneKeyHelper, control: VPhoneControl) { self.keyHelper = keyHelper diff --git a/sources/vphone-cli/VPhoneMenuNotify.swift b/sources/vphone-cli/VPhoneMenuNotify.swift new file mode 100644 index 00000000..8d01f309 --- /dev/null +++ b/sources/vphone-cli/VPhoneMenuNotify.swift @@ -0,0 +1,305 @@ +import AppKit + +// MARK: - Notify Menu + +private enum NotifyEntry { + case section(String) + case notification(name: String, label: String) +} + +private let knownNotifications: [NotifyEntry] = [ + .section("SpringBoard"), + .notification(name: "com.apple.springboard.lockstate", label: "Lock State"), + .notification(name: "com.apple.springboard.lockcomplete", label: "Lock Complete"), + .notification(name: "com.apple.springboard.hasBlankedScreen", label: "Screen Blanked"), + .notification(name: "com.apple.springboard.pluggedin", label: "Plugged In"), + .notification(name: "com.apple.springboard.finishedstartup", label: "SpringBoard Finished Startup"), + .notification(name: "com.apple.springboard.screenshotService", label: "Screenshot Service"), + .notification(name: "com.apple.springboard.attemptactivation", label: "Attempt Activation"), + + .section("Display & Backlight"), + .notification(name: "com.apple.iokit.hid.displayStatus", label: "Display Status"), + .notification(name: "com.apple.backboardd.backlight.changed", label: "Backlight Changed"), + + .section("Power & Battery"), + .notification(name: "com.apple.system.lowpowermode", label: "Low Power Mode"), + .notification(name: "com.apple.system.thermalpressurelevel", label: "Thermal Pressure Level"), + + .section("Network & Telephony"), + .notification(name: "com.apple.system.config.network_change", label: "Network Change"), + .notification(name: "com.apple.MobileSignal.CTRegistrationDataStatusChanged", label: "Cellular Registration Changed"), + + .section("Locale & Time"), + .notification(name: "com.apple.language.changed", label: "Language Changed"), + .notification(name: "com.apple.system.timezone", label: "Timezone Changed"), + .notification(name: "com.apple.system.clock_set", label: "Clock Set"), + + .section("Location"), + .notification(name: "com.apple.locationd.authorization.changed", label: "Location Authorization Changed"), + + .section("Keyboard & UI"), + .notification(name: "com.apple.UIKit.keyboard.visibility.changed", label: "Keyboard Visibility Changed"), + .notification(name: "com.apple.accessibility.cache.ax.app.notification", label: "Accessibility App Notification"), + + .section("Keybag & Security"), + .notification(name: "com.apple.mobile.keybagd.lock_status", label: "Keybag Lock Status"), + .notification(name: "com.apple.mobile.keybagd.first_unlock", label: "Keybag First Unlock"), + + .section("App Lifecycle"), + .notification(name: "com.apple.frontboard.systemapp.didlaunch", label: "System App Did Launch"), + .notification(name: "com.apple.mobile.application_installed", label: "App Installed"), + .notification(name: "com.apple.mobile.application_uninstalled", label: "App Uninstalled"), + + .section("Lockdown & Device State"), + .notification(name: "com.apple.mobile.lockdown.phone_number_changed", label: "Phone Number Changed"), + .notification(name: "com.apple.mobile.lockdown.device_name_changed", label: "Device Name Changed"), + .notification(name: "com.apple.mobile.lockdown.timezone_changed", label: "Lockdown Timezone Changed"), + .notification(name: "com.apple.mobile.lockdown.trusted_host_attached", label: "Trusted Host Attached"), + .notification(name: "com.apple.mobile.lockdown.host_attached", label: "Host Attached"), + .notification(name: "com.apple.mobile.lockdown.host_detached", label: "Host Detached"), + .notification(name: "com.apple.mobile.lockdown.activation_state", label: "Activation State"), + .notification(name: "com.apple.mobile.lockdown.disk_usage_changed", label: "Disk Usage Changed"), + .notification(name: "com.apple.mobile.developer_image_mounted", label: "Developer Image Mounted"), + + .section("Sync"), + .notification(name: "com.apple.itunes-mobdev.syncWillStart", label: "Sync Will Start"), + .notification(name: "com.apple.itunes-mobdev.syncDidStart", label: "Sync Did Start"), + .notification(name: "com.apple.itunes-mobdev.syncDidFinish", label: "Sync Did Finish"), + .notification(name: "com.apple.mobile.data_sync.domain_changed", label: "Data Sync Domain Changed"), + .notification(name: "com.apple.mobile.backup.domain_changed", label: "Backup Domain Changed"), + + .section("Backup & Restore"), + .notification(name: "com.apple.MobileSync.BackupAgent.RestoreStarted", label: "Restore Started"), + + .section("Filesystem"), + .notification(name: "com.apple.system.lowdiskspace", label: "Low Disk Space"), + .notification(name: "com.apple.system.lowdiskspace.system", label: "Low Disk Space (System)"), + .notification(name: "com.apple.system.lowdiskspace.user", label: "Low Disk Space (User)"), + .notification(name: "com.apple.system.kernel.mount", label: "VFS Mount"), + .notification(name: "com.apple.system.kernel.unmount", label: "VFS Unmount"), + .notification(name: "com.apple.system.kernel.mountupdate", label: "VFS Mount Update"), + + .section("Other"), + .notification(name: "com.apple.system.hostname", label: "Hostname Changed"), + .notification(name: "com.apple.system.logger.message", label: "ASL Logger Message"), + .notification(name: "com.apple.AddressBook.PreferenceChanged", label: "Address Book Preference Changed"), + .notification(name: "com.apple.itdbprep.notification.didEnd", label: "iTunes DB Prep Did End"), +] + +extension VPhoneMenuController { + func updateNotifyAvailability(available: Bool) { + notifyPostItem?.isEnabled = available + } + + @objc func postNotification() { + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + panel.title = "Send Darwin Notification" + panel.center() + + // Preset popup + let presetLbl = NSTextField(labelWithString: "Preset:") + presetLbl.frame = NSRect(x: 20, y: 202, width: 60, height: 18) + + let popup = NSPopUpButton(frame: NSRect(x: 80, y: 198, width: 380, height: 26), pullsDown: false) + popup.menu?.autoenablesItems = false + popup.addItem(withTitle: "Custom") + for entry in knownNotifications { + switch entry { + case let .section(title): + popup.menu?.addItem(NSMenuItem.separator()) + let header = NSMenuItem(title: title, action: nil, keyEquivalent: "") + header.isEnabled = false + header.attributedTitle = NSAttributedString( + string: title, + attributes: [.font: NSFont.boldSystemFont(ofSize: 11)] + ) + popup.menu?.addItem(header) + case let .notification(name, label): + popup.addItem(withTitle: "\(label) (\(name))") + } + } + + // Name field + let nameLbl = NSTextField(labelWithString: "Notification name:") + nameLbl.frame = NSRect(x: 20, y: 172, width: 440, height: 18) + + let nameField = NSTextField(frame: NSRect(x: 20, y: 146, width: 440, height: 22)) + nameField.placeholderString = "com.apple.example.notification" + + // Wire preset selection to populate name field + let popupHandler = NotifyPopupHandler(nameField: nameField) + popup.target = popupHandler + popup.action = #selector(NotifyPopupHandler.presetSelected(_:)) + + // Include state checkbox + let stateCheck = NSButton(checkboxWithTitle: "Include state payload (UInt64)", target: nil, action: nil) + stateCheck.frame = NSRect(x: 20, y: 116, width: 300, height: 20) + stateCheck.state = .off + + // State field + let stateField = NSTextField(frame: NSRect(x: 20, y: 88, width: 440, height: 22)) + stateField.placeholderString = "0" + stateField.isEnabled = false + + // Wire checkbox to toggle state field + let checkHandler = NotifyCheckHandler(stateField: stateField) + stateCheck.target = checkHandler + stateCheck.action = #selector(NotifyCheckHandler.toggled(_:)) + + // Status label (inline feedback instead of separate alert) + let statusLabel = NSTextField(labelWithString: "") + statusLabel.frame = NSRect(x: 20, y: 48, width: 340, height: 18) + statusLabel.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) + statusLabel.textColor = .secondaryLabelColor + + // Buttons + let postHandler = NotifyPostHandler( + control: control, nameField: nameField, + stateCheck: stateCheck, stateField: stateField, + statusLabel: statusLabel + ) + + let ok = NSButton(frame: NSRect(x: 370, y: 12, width: 90, height: 28)) + ok.title = "Post" + ok.bezelStyle = .rounded + ok.keyEquivalent = "\r" + ok.target = postHandler + ok.action = #selector(NotifyPostHandler.post(_:)) + + let close = NSButton(frame: NSRect(x: 270, y: 12, width: 90, height: 28)) + close.title = "Close" + close.bezelStyle = .rounded + close.keyEquivalent = "\u{1b}" + close.target = NSApp + close.action = #selector(NSApplication.stopModal as (NSApplication) -> () -> Void) + + // Stop the modal session when the close button (red X) is clicked + let closeDelegate = ModalCloseDelegate() + panel.delegate = closeDelegate + + panel.contentView?.addSubview(presetLbl) + panel.contentView?.addSubview(popup) + panel.contentView?.addSubview(nameLbl) + panel.contentView?.addSubview(nameField) + panel.contentView?.addSubview(stateCheck) + panel.contentView?.addSubview(stateField) + panel.contentView?.addSubview(statusLabel) + panel.contentView?.addSubview(ok) + panel.contentView?.addSubview(close) + + NSApp.runModal(for: panel) + panel.orderOut(nil) + _ = popupHandler + _ = checkHandler + _ = postHandler + _ = closeDelegate + } +} + +// MARK: - Action Handlers + +@MainActor +private final class NotifyPopupHandler: NSObject { + let nameField: NSTextField + + init(nameField: NSTextField) { + self.nameField = nameField + } + + @objc func presetSelected(_ sender: NSPopUpButton) { + guard let title = sender.selectedItem?.title else { return } + // Find the notification whose formatted title matches + for entry in knownNotifications { + if case let .notification(name, label) = entry, + title == "\(label) (\(name))" { + nameField.stringValue = name + return + } + } + } +} + +@MainActor +private final class NotifyCheckHandler: NSObject { + let stateField: NSTextField + + init(stateField: NSTextField) { + self.stateField = stateField + } + + @objc func toggled(_ sender: NSButton) { + let isOn = sender.state == .on + stateField.isEnabled = isOn + if isOn { + stateField.window?.makeFirstResponder(stateField) + } + } +} + +@MainActor +private final class NotifyPostHandler: NSObject { + let control: VPhoneControl + let nameField: NSTextField + let stateCheck: NSButton + let stateField: NSTextField + let statusLabel: NSTextField + + init( + control: VPhoneControl, nameField: NSTextField, + stateCheck: NSButton, stateField: NSTextField, + statusLabel: NSTextField + ) { + self.control = control + self.nameField = nameField + self.stateCheck = stateCheck + self.stateField = stateField + self.statusLabel = statusLabel + } + + @objc func post(_ sender: NSButton) { + let name = nameField.stringValue + guard !name.isEmpty else { + statusLabel.textColor = .systemOrange + statusLabel.stringValue = "Enter a notification name." + return + } + + var state: UInt64? + if stateCheck.state == .on { + guard let parsed = UInt64(stateField.stringValue) else { + statusLabel.textColor = .systemOrange + statusLabel.stringValue = "Invalid UInt64 value." + return + } + state = parsed + } + + statusLabel.textColor = .secondaryLabelColor + statusLabel.stringValue = "Posting..." + sender.isEnabled = false + + Task { + do { + try await control.notifyPost(name: name, state: state) + let detail = state.map { " (state: \($0))" } ?? "" + statusLabel.textColor = .systemGreen + statusLabel.stringValue = "Posted: \(name)\(detail)" + } catch { + statusLabel.textColor = .systemRed + statusLabel.stringValue = "\(error)" + } + sender.isEnabled = true + } + } +} + +private final class ModalCloseDelegate: NSObject, NSWindowDelegate { + func windowWillClose(_ notification: Notification) { + NSApp.stopModal() + } +}