Compare commits

...

1 Commits

Author SHA1 Message Date
Brendan Allan 5f6b65b1ba attempt communication notifications 2026-01-22 15:12:44 +08:00
11 changed files with 409 additions and 5 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+34
View File
@@ -2716,6 +2716,16 @@ dependencies = [
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-contacts"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b034b578389f89a85c055eacc8d8b368be5f04a6c1b07f672bf3aec21d0ef621"
dependencies = [
"objc2 0.6.3",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-core-data"
version = "0.3.2"
@@ -2836,6 +2846,21 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-intents"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80fac082e6de12282b24ea745e9707cd374363c7ae0b2f813bf1813cfc289325"
dependencies = [
"bitflags 2.10.0",
"block2 0.6.2",
"objc2 0.6.3",
"objc2-contacts",
"objc2-core-graphics",
"objc2-core-location",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
@@ -2944,7 +2969,10 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
dependencies = [
"bitflags 2.10.0",
"block2 0.6.2",
"objc2 0.6.3",
"objc2-core-location",
"objc2-foundation 0.3.2",
]
@@ -2986,9 +3014,15 @@ dependencies = [
name = "opencode-desktop"
version = "0.0.0"
dependencies = [
"base64 0.22.1",
"block2 0.6.2",
"futures",
"gtk",
"listeners",
"objc2 0.6.3",
"objc2-foundation 0.3.2",
"objc2-intents",
"objc2-user-notifications",
"reqwest",
"semver",
"serde",
+30 -1
View File
@@ -4,6 +4,7 @@ version = "0.0.0"
description = "The open source AI coding agent"
authors = ["Anomaly Innovations"]
edition = "2024"
default-run = "opencode-desktop"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -42,11 +43,39 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"
uuid = { version = "1.19.0", features = ["v4"] }
tauri-plugin-decorum = "1.1.1"
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18.2"
webkit2gtk = "=2.0.1"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-foundation = { version = "0.3", features = [
"NSData",
"NSString",
"NSPersonNameComponents",
"NSArray",
] }
objc2-intents = { version = "0.3", features = [
"INIntent",
"INSendMessageIntent",
"INPerson",
"INPersonHandle",
"INImage",
"INSpeakableString",
"INOutgoingMessageType",
"INInteraction",
"block2",
] }
objc2-user-notifications = { version = "0.3", features = [
"UNNotificationContent",
"UNNotificationRequest",
"UNUserNotificationCenter",
"UNNotificationTrigger",
"block2",
] }
base64 = "0.22"
block2 = "0.6.2"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.61", features = [
"Win32_Foundation",
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
</array>
</dict>
</plist>
+81
View File
@@ -0,0 +1,81 @@
#!/bin/bash
set -e
# This script builds and packages the example into a macOS bundle so it can be properly tested on macOS
cargo build --bin test
export TARGET_DIR=./target
# Clean any existing app bundle to avoid stale files
rm -rf "$TARGET_DIR/TestExample.app"
mkdir -p "$TARGET_DIR/TestExample.app/Contents/MacOS"
cp "$TARGET_DIR/debug/test" "$TARGET_DIR/TestExample.app/Contents/MacOS"
cat <<EOF > "$TARGET_DIR/TestExample.app/Contents/Info.plist"
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>TestExample</string>
<key>CFBundleDisplayName</key>
<string>Test Example</string>
<!-- TODO set icon -->
<key>CFBundleVersion</key>
<string>0.1.0</string>
<key>CFBundleIdentifier</key>
<string>ai.opencode.desktop.test</string>
<key>CFBundleExecutable</key>
<string>test</string>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>NSUserNotificationsUsageDescription</key>
<string>Allow Test Example to send notifications</string>
<key>NSExtension</key>
<dict>
<key>IntentsSupported</key>
<array>
<string>INSendMessageIntent</string>
</array>
</dict>
</dict>
</plist>
EOF
echo "> code signing"
export APPLE_SIGNING_IDENTITY=XF923AZS22
# Verify signing identity exists
if ! security find-identity -v -p codesigning | grep -q "$APPLE_SIGNING_IDENTITY"; then
echo "ERROR: Signing identity $APPLE_SIGNING_IDENTITY not found"
echo "Available identities:"
security find-identity -v -p codesigning
exit 1
fi
# Create entitlements file (outside app bundle to avoid signing issues)
cat <<EOF > "$TARGET_DIR/Entitlements.plist"
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.usernotifications.communication</key>
<true/>
</dict>
</plist>
EOF
# Sign the application bundle (sign executable directly, not with --deep, to properly apply entitlements)
codesign -s "$APPLE_SIGNING_IDENTITY" --entitlements "$TARGET_DIR/Entitlements.plist" --force -v "$TARGET_DIR/TestExample.app"
# Verify the signature
echo "> verifying signature"
codesign -v "$TARGET_DIR/TestExample.app/"
echo "> running application"
RUST_LOG=trace "$TARGET_DIR/TestExample.app/Contents/MacOS/test"
@@ -26,5 +26,7 @@
<true/>
<key>com.apple.security.personal-information.photos-library</key>
<true/>
<key>com.apple.developer.usernotifications.communication</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,14 @@
use std::time::Duration;
use opencode_lib::notification_macos;
#[tokio::main]
async fn main() {
// loop {
let sent = notification_macos::send_communication_notification(
"title", "body", "sender", None, "conv-id",
);
dbg!(sent);
tokio::time::sleep(Duration::from_secs(2)).await;
// }
}
+38 -1
View File
@@ -1,6 +1,7 @@
mod cli;
#[cfg(windows)]
mod job_object;
pub mod notification_macos;
mod window_customizer;
use cli::{install_cli, sync_cli};
@@ -22,6 +23,7 @@ use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_store::StoreExt;
use tokio::sync::oneshot;
use crate::notification_macos::send_communication_notification;
use crate::window_customizer::PinchZoomDisablePlugin;
const SETTINGS_STORE: &str = "opencode.settings.dat";
@@ -137,6 +139,31 @@ async fn set_default_server_url(app: AppHandle, url: Option<String>) -> Result<(
Ok(())
}
#[tauri::command]
fn send_rich_notification(
title: String,
body: String,
sender_name: String,
sender_image_base64: Option<String>,
conversation_id: String,
) -> Result<bool, String> {
use base64::Engine;
let image_data = sender_image_base64
.as_ref()
.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok());
let success = notification_macos::send_communication_notification(
&title,
&body,
&sender_name,
image_data.as_deref(),
&conversation_id,
);
Ok(success)
}
fn get_sidecar_port() -> u32 {
option_env!("OPENCODE_PORT")
.map(|s| s.to_string())
@@ -283,7 +310,8 @@ pub fn run() {
install_cli,
ensure_server_ready,
get_default_server_url,
set_default_server_url
set_default_server_url,
send_rich_notification
])
.setup(move |app| {
let app = app.handle().clone();
@@ -370,6 +398,15 @@ pub fn run() {
});
}
{
tauri::async_runtime::spawn(async move {
loop {
send_communication_notification("test", "test", "test", None, "test");
tokio::time::sleep(Duration::from_secs(1)).await;
}
});
}
{
let app = app.clone();
tauri::async_runtime::spawn(async move {
@@ -0,0 +1,196 @@
//! macOS Communication Notifications
//!
//! Provides Discord-style notifications with custom avatar icons
//! and the app icon shown as a small badge.
/// Sends a macOS Communication Notification with custom sender avatar.
///
/// Returns `true` if successful, `false` if fallback should be used.
pub fn send_communication_notification(
title: &str,
body: &str,
sender_name: &str,
sender_image_data: Option<&[u8]>,
conversation_id: &str,
) -> bool {
#[cfg(target_os = "macos")]
{
use base64::Engine;
use objc2_foundation::{NSData, NSString};
use objc2_intents::{
INImage, INInteraction, INInteractionDirection, INOutgoingMessageType, INPerson,
INPersonHandle, INPersonHandleType, INSendMessageIntent,
};
use objc2_user_notifications::{
UNMutableNotificationContent, UNNotificationRequest, UNUserNotificationCenter,
};
unsafe {
const PNG_AVATAR_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAABLUlEQVR4nO3SIQEAMAzAsOmcfx+7jIMGhBd0bvfomt8BGAADYAAMgAEwAAbAABgAA2AADIABMAAGwAAYAANgAAyAATAABsAAGAADYAAMgAEwAAbAABgAA2AADIABMAAGwAAYAANggDgDxBkgzgBxBogzQJwB4gwQZ4A4A8QZIM4AcQaIM0CcAeIMEGeAOAPEGSDOAHEGiDNAnAHiDBBngDgDxBkgzgBxBogzQJwB4gwQZ4A4A8QZIM4AcQaIM0CcAeIMEGeAOAPEGSDOAHEGiDNAnAHiDBBngDgDxBkgzgBxBogzQJwB4gwQZ4A4A8QZIM4AcQaIM0CcAeIMEGeAOAPEGSDOAHEGiDNAnAHiDBBngDgDxBkgzgBxBogzQJwB4gwQZ4A4A8QZIM4AcQ+TX4oVvvtE5AAAAABJRU5ErkJggg==";
// Check if we have a valid bundle (required for UNUserNotificationCenter)
use objc2_foundation::{NSArray, NSBundle, NSError};
let main_bundle = NSBundle::mainBundle();
let bundle_id = main_bundle.bundleIdentifier();
if bundle_id.is_none() {
// No bundle identifier - UNUserNotificationCenter will crash
// This happens when running as a plain binary (e.g., cargo run --bin test)
// Tauri handles this by running within an app bundle
println!(
"[notification_macos] No bundle identifier found - Communication Notifications unavailable"
);
println!(
"[notification_macos] This is expected when running via 'cargo run'. Use 'tauri dev' or build an app bundle to test notifications."
);
return false;
}
// 1. Create INImage from binary data
use objc2::{AnyThread, runtime::ProtocolObject};
use objc2_user_notifications::{
UNNotificationContentProviding, UNTimeIntervalNotificationTrigger,
};
// let data = match sender_image_data {
// Some(data) => Some(data.to_vec()),
// None => base64::engine::general_purpose::STANDARD
// .decode(PNG_AVATAR_BASE64)
// .ok(),
// };
let img_bytes = std::fs::read(
"/Users/brendonovich/github.com/sst/opencode/packages/desktop/placeholder.png",
)
.unwrap();
let avatar = INImage::imageWithImageData(&NSData::with_bytes(img_bytes.as_ref()));
// 2. Create person handle for sender
let handle_value = NSString::from_str("1234");
let person_handle = INPersonHandle::initWithValue_type(
INPersonHandle::alloc(),
Some(&handle_value),
INPersonHandleType::Unknown,
);
// dbg!(avatar.is_some());
// 3. Create sender INPerson with avatar
let display_name = NSString::from_str(sender_name);
let sender = INPerson::initWithPersonHandle_nameComponents_displayName_image_contactIdentifier_customIdentifier_isMe(
INPerson::alloc(),
&person_handle,
None, // name components
Some(&display_name),
Some(&avatar),
None, // contact identifier
None, // custom identifier
false, // isMe
);
let handle_value = NSString::from_str("5678");
let me_handle = INPersonHandle::initWithValue_type(
INPersonHandle::alloc(),
Some(&handle_value),
INPersonHandleType::Unknown,
);
let me = INPerson::initWithPersonHandle_nameComponents_displayName_image_contactIdentifier_customIdentifier_isMe(
INPerson::alloc(),
&me_handle,
None, // name components
None,
None,
None, // contact identifier
None, // custom identifier
true, // isMe
);
// 5. Create INSendMessageIntent
let content_str = NSString::from_str(body);
let conv_id = NSString::from_str(conversation_id);
let intent = INSendMessageIntent::initWithRecipients_outgoingMessageType_content_speakableGroupName_conversationIdentifier_serviceName_sender_attachments(
INSendMessageIntent::alloc(),
Some(&NSArray::from_retained_slice(&[me])),
INOutgoingMessageType::OutgoingMessageText,
Some(&content_str),
None, // speakable group name
Some(&conv_id),
None, // service name
Some(&sender),
None, // attachments
);
// 6. Set image for sender parameter (ensures avatar is used)
// if let Some(ref avatar_img) = avatar {
let param_name = NSString::from_str("sender");
intent.setImage_forParameterNamed(Some(&avatar), &param_name);
// }
// 7. Create and donate interaction
let interaction =
INInteraction::initWithIntent_response(INInteraction::alloc(), &intent, None);
interaction.setDirection(INInteractionDirection::Incoming);
// Donate the interaction
interaction.donateInteractionWithCompletion(None);
let title = title.to_string();
let body = body.to_string();
// 8. Create notification content with Communication Notification category
let content = UNMutableNotificationContent::new();
content.setTitle(&NSString::from_str(&title));
content.setBody(&NSString::from_str(&body));
content.setCategoryIdentifier(&NSString::from_str("message"));
// Set the communication context using thread identifier
// content.setThreadIdentifier(&conv_id);
fn as_protocol(
msg_intent: &INSendMessageIntent,
) -> &ProtocolObject<dyn UNNotificationContentProviding> {
// SAFETY: `INSendMessageIntent` implements the `UNNotificationContentProviding` protocol,
// even thoough it doesn't currently implement the trait: https://github.com/madsmtm/objc2/issues/814
unsafe { std::mem::transmute(msg_intent) }
}
let content = content
.contentByUpdatingWithProvider_error(as_protocol(&intent))
.unwrap();
// 9. Create and deliver notification request
let identifier = NSString::from_str(&uuid::Uuid::new_v4().to_string());
// let trigger = UNTimeIntervalNotificationTrigger::triggerWithTimeInterval_repeats(
// 0.1, // deliver almost immediately
// false,
// );
let request = UNNotificationRequest::requestWithIdentifier_content_trigger(
&identifier,
&content,
None,
// Some(&trigger),
);
let center = UNUserNotificationCenter::currentNotificationCenter();
center.addNotificationRequest_withCompletionHandler(
&request,
Some(&block2::RcBlock::new(move |err: *mut NSError| {
dbg!(err.is_null());
if !err.is_null() {
unsafe {
dbg!((*err).domain());
dbg!((*err).code());
dbg!((*err).localizedDescription());
}
}
})),
);
}
true
}
#[cfg(not(target_os = "macos"))]
false
}
@@ -1,4 +1,4 @@
use tauri::{plugin::Plugin, Manager, Runtime, Window};
use tauri::{Manager, Runtime, Window, plugin::Plugin};
pub struct PinchZoomDisablePlugin;
@@ -21,8 +21,8 @@ impl<R: Runtime> Plugin<R> for PinchZoomDisablePlugin {
let _ = webview_window.with_webview(|_webview| {
#[cfg(target_os = "linux")]
unsafe {
use gtk::glib::ObjectExt;
use gtk::GestureZoom;
use gtk::glib::ObjectExt;
use webkit2gtk::glib::gobject_ffi;
if let Some(data) = _webview.inner().data::<GestureZoom>("wk-view-zoom-gesture") {
+2 -1
View File
@@ -43,7 +43,8 @@
"targets": ["deb", "rpm", "dmg", "nsis", "app"],
"externalBin": ["sidecars/opencode-cli"],
"macOS": {
"entitlements": "./entitlements.plist"
"entitlements": "./entitlements.plist",
"infoPlist": "./Info.plist"
},
"windows": {
"nsis": {