diff --git a/README.md b/README.md
index 9898811..28170be 100644
--- a/README.md
+++ b/README.md
@@ -98,7 +98,7 @@ Download artifacts from the GitHub Actions release workflow or from GitHub Relea
See [docs/RELEASES.md](docs/RELEASES.md) for artifact download and smoke-test steps.
-The macOS release also includes an unsigned `Stringcast.app` wrapper. See [docs/MACOS_APP.md](docs/MACOS_APP.md) for build and testing notes.
+The macOS release also includes an unsigned `Stringcast.app` bundle with a companion menu-bar item. See [docs/MACOS_APP.md](docs/MACOS_APP.md) for build and testing notes.
## Development
diff --git a/docs/MACOS_APP.md b/docs/MACOS_APP.md
index 7a67e16..355ac57 100644
--- a/docs/MACOS_APP.md
+++ b/docs/MACOS_APP.md
@@ -1,8 +1,8 @@
# macOS App Wrapper
-Stringcast can be packaged as an unsigned `.app` bundle around the Rust CLI binary. This gives macOS a stable app identity for permissions and Keychain prompts while the runtime still uses the existing CLI internally.
+Stringcast can be packaged as an unsigned `.app` bundle where the Rust runtime is the app executable. This keeps the permission-sensitive keyboard listening and replacement work under `Stringcast.app` instead of a separate helper binary.
-This is not a full menu-bar UI yet. Launching the app starts `stringcast run` in the background.
+Launching the app starts the runtime and adds a `Stringcast` companion item to the macOS menu bar.
## Build Locally
@@ -13,6 +13,18 @@ cargo build --release
packaging/macos/build_app.sh
```
+To include the app icon, save a square PNG at:
+
+```text
+packaging/macos/StringcastIcon.png
+```
+
+The packaging script converts it to `Contents/Resources/StringcastIcon.icns`. You can also use a different source path for local builds:
+
+```bash
+STRINGCAST_ICON=/path/to/icon.png packaging/macos/build_app.sh
+```
+
The app is written to:
```text
@@ -25,7 +37,17 @@ Open it:
open dist/macos/Stringcast.app
```
-Stop it:
+Use the menu-bar item to:
+
+- View status
+- Request permissions
+- Run an API test
+- Open the config file
+- Open logs
+- Reveal the app executable
+- Quit
+
+Stop it from Terminal if needed:
```bash
pkill -f "Stringcast.app"
@@ -41,18 +63,28 @@ macOS may ask for:
If permissions were granted to a terminal binary before, macOS may ask again because `Stringcast.app` has a different app identity.
+Grant permissions to `Stringcast.app`. The runtime now runs as the app executable:
+
+- `dist/macos/Stringcast.app/Contents/MacOS/Stringcast`
+
+If macOS still shows a stale `Stringcast` entry from an older local build, remove the old entry and add the rebuilt `Stringcast.app` again. To locate the current executable manually, use `Reveal App Executable` from the menu, or open Finder's Go to Folder dialog and paste:
+
+```text
+dist/macos/Stringcast.app/Contents/MacOS/
+```
+
+The app does not block startup on this permission check. If permissions are missing, Stringcast can still show as running, but keyboard listening or text replacement may not work until macOS grants the required permissions.
+
## Current Limitations
- The app is unsigned.
-- There is no menu-bar UI yet.
-- There is no visible quit control yet.
-- Logs are not surfaced in an app window.
+- There is no custom app icon yet.
+- Logs open in Finder rather than an in-app viewer.
- Packaging does not create a DMG or installer yet.
## Next Packaging Steps
- Add an icon.
-- Add a menu-bar process with Start/Stop/Quit controls.
- Add log/status display.
- Add code signing and notarization.
- Produce a DMG for end users.
diff --git a/docs/RELEASES.md b/docs/RELEASES.md
index e70a4b8..5c2055a 100644
--- a/docs/RELEASES.md
+++ b/docs/RELEASES.md
@@ -82,6 +82,8 @@ Or launch the app wrapper:
open Stringcast.app
```
+The app runs Stringcast as the app executable and adds a companion menu-bar item with status, request permissions, API test, config, logs, reveal executable, and quit actions.
+
macOS may ask for:
- Accessibility permission for keyboard automation.
diff --git a/packaging/macos/Info.plist b/packaging/macos/Info.plist
index ab8751c..1f42101 100644
--- a/packaging/macos/Info.plist
+++ b/packaging/macos/Info.plist
@@ -14,6 +14,8 @@
6.0
CFBundleName
Stringcast
+ CFBundleIconFile
+ StringcastIcon
CFBundlePackageType
APPL
CFBundleShortVersionString
diff --git a/packaging/macos/Stringcast b/packaging/macos/Stringcast
deleted file mode 100755
index 62564fa..0000000
--- a/packaging/macos/Stringcast
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/bin/sh
-set -eu
-
-APP_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
-BIN="$APP_DIR/Resources/stringcast"
-
-exec "$BIN" run
diff --git a/packaging/macos/StringcastIcon.png b/packaging/macos/StringcastIcon.png
new file mode 100644
index 0000000..9f614d5
Binary files /dev/null and b/packaging/macos/StringcastIcon.png differ
diff --git a/packaging/macos/StringcastMenu.swift b/packaging/macos/StringcastMenu.swift
new file mode 100644
index 0000000..514e060
--- /dev/null
+++ b/packaging/macos/StringcastMenu.swift
@@ -0,0 +1,196 @@
+import AppKit
+import Darwin
+import Foundation
+
+final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
+ private var statusItem: NSStatusItem!
+ private let menu = NSMenu()
+ private let stateItem = NSMenuItem(title: "Stringcast: Running", action: nil, keyEquivalent: "")
+ private let runtimePid = Int32(ProcessInfo.processInfo.environment["STRINGCAST_APP_RUNTIME_PID"] ?? "")
+
+ private var executableURL: URL {
+ Bundle.main.bundleURL
+ .appendingPathComponent("Contents")
+ .appendingPathComponent("MacOS")
+ .appendingPathComponent("Stringcast")
+ }
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ configureStatusItem()
+ }
+
+ private func configureStatusItem() {
+ statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
+ statusItem.button?.title = "Stringcast On"
+ statusItem.menu = menu
+
+ menu.delegate = self
+ stateItem.isEnabled = false
+ menu.addItem(stateItem)
+ menu.addItem(NSMenuItem.separator())
+
+ addMenuItem("Status", #selector(showStatus), "i")
+ addMenuItem("Request Permissions", #selector(requestPermissions), "r")
+ addMenuItem("Run API Test", #selector(runApiTest), "t")
+ addMenuItem("Open Config", #selector(openConfig), "o")
+ addMenuItem("Open Logs", #selector(openLogs), "l")
+ addMenuItem("Reveal App Executable", #selector(revealAppExecutable), "h")
+
+ menu.addItem(NSMenuItem.separator())
+ addMenuItem("Quit", #selector(quit), "q")
+ updateMenuState()
+ }
+
+ func menuWillOpen(_ menu: NSMenu) {
+ updateMenuState()
+ }
+
+ private func addMenuItem(_ title: String, _ action: Selector, _ keyEquivalent: String = "") {
+ let item = NSMenuItem(title: title, action: action, keyEquivalent: keyEquivalent)
+ item.target = self
+ menu.addItem(item)
+ }
+
+ @objc private func showStatus() {
+ runCommand(title: "Stringcast Status", arguments: ["status"])
+ }
+
+ @objc private func requestPermissions() {
+ DispatchQueue.global(qos: .userInitiated).async {
+ let result = self.commandOutput(arguments: ["request-permissions"])
+ DispatchQueue.main.async {
+ self.showPermissionAlert(message: result.output)
+ }
+ }
+ }
+
+ @objc private func runApiTest() {
+ runCommand(title: "Stringcast API Test", arguments: ["api-test"])
+ }
+
+ @objc private func openConfig() {
+ DispatchQueue.global(qos: .userInitiated).async {
+ let result = self.commandOutput(arguments: ["show-config"])
+ DispatchQueue.main.async {
+ guard result.exitCode == 0 else {
+ self.showAlert(title: "Could Not Find Config", message: result.output)
+ return
+ }
+
+ let path = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
+ NSWorkspace.shared.open(URL(fileURLWithPath: path))
+ }
+ }
+ }
+
+ @objc private func openLogs() {
+ NSWorkspace.shared.open(logDirectoryURL())
+ }
+
+ @objc private func revealAppExecutable() {
+ NSWorkspace.shared.activateFileViewerSelecting([executableURL])
+ }
+
+ @objc private func quit() {
+ if let runtimePid, runtimePid > 0 {
+ Darwin.kill(runtimePid, SIGTERM)
+ }
+ NSApp.terminate(nil)
+ }
+
+ private func runCommand(title: String, arguments: [String]) {
+ DispatchQueue.global(qos: .userInitiated).async {
+ let result = self.commandOutput(arguments: arguments)
+ DispatchQueue.main.async {
+ self.showAlert(title: title, message: result.output, isError: result.exitCode != 0)
+ }
+ }
+ }
+
+ private func commandOutput(arguments: [String]) -> (exitCode: Int32, output: String) {
+ let process = Process()
+ process.executableURL = executableURL
+ process.arguments = arguments
+
+ let pipe = Pipe()
+ process.standardOutput = pipe
+ process.standardError = pipe
+
+ do {
+ try process.run()
+ process.waitUntilExit()
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
+ let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
+ return (process.terminationStatus, output?.isEmpty == false ? output! : "(no output)")
+ } catch {
+ return (1, error.localizedDescription)
+ }
+ }
+
+ private func updateMenuState() {
+ let running = runtimeIsRunning()
+ stateItem.title = running ? "Stringcast: Running" : "Stringcast: Stopped"
+ statusItem.button?.title = running ? "Stringcast On" : "Stringcast Off"
+ }
+
+ private func runtimeIsRunning() -> Bool {
+ guard let runtimePid, runtimePid > 0 else {
+ return false
+ }
+ return Darwin.kill(runtimePid, 0) == 0
+ }
+
+ private func logDirectoryURL() -> URL {
+ let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
+ let directory = base.appendingPathComponent("Stringcast", isDirectory: true)
+ try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ return directory
+ }
+
+ private func showAlert(title: String, message: String, isError: Bool = false) {
+ let alert = NSAlert()
+ alert.messageText = title
+ alert.informativeText = message
+ alert.alertStyle = isError ? .warning : .informational
+ alert.addButton(withTitle: "OK")
+ alert.runModal()
+ }
+
+ private func showPermissionAlert(message: String) {
+ let alert = NSAlert()
+ alert.messageText = "Stringcast Needs Permissions"
+ alert.informativeText = """
+ \(message)
+
+ Grant permissions to Stringcast.app. The runtime now runs as the app executable:
+ \(executableURL.path)
+ """
+ alert.alertStyle = .warning
+ alert.addButton(withTitle: "Open Accessibility")
+ alert.addButton(withTitle: "Open Input Monitoring")
+ alert.addButton(withTitle: "Reveal App Executable")
+ alert.addButton(withTitle: "OK")
+
+ let response = alert.runModal()
+ if response == .alertFirstButtonReturn {
+ openSystemSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
+ } else if response == .alertSecondButtonReturn {
+ openSystemSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent")
+ } else if response == .alertThirdButtonReturn {
+ revealAppExecutable()
+ }
+ }
+
+ private func openSystemSettings(_ urlString: String) {
+ guard let url = URL(string: urlString) else {
+ return
+ }
+ NSWorkspace.shared.open(url)
+ }
+}
+
+let app = NSApplication.shared
+let delegate = AppDelegate()
+app.delegate = delegate
+app.setActivationPolicy(.accessory)
+app.run()
diff --git a/packaging/macos/build_app.sh b/packaging/macos/build_app.sh
index ff8c93b..c8f12ca 100755
--- a/packaging/macos/build_app.sh
+++ b/packaging/macos/build_app.sh
@@ -5,7 +5,7 @@ if [ "${1:-}" = "--help" ]; then
cat <<'USAGE'
usage: packaging/macos/build_app.sh [release-binary-path] [output-dir]
-Builds an unsigned Stringcast.app bundle around the Rust CLI binary.
+Builds an unsigned Stringcast.app bundle where the Rust runtime is the app executable.
Defaults:
release-binary-path: target/release/stringcast
@@ -20,6 +20,7 @@ APP_DIR="$OUT_DIR/Stringcast.app"
CONTENTS_DIR="$APP_DIR/Contents"
MACOS_DIR="$CONTENTS_DIR/MacOS"
RESOURCES_DIR="$CONTENTS_DIR/Resources"
+ICON_SOURCE=${STRINGCAST_ICON:-packaging/macos/StringcastIcon.png}
if [ ! -f "$BIN_PATH" ]; then
echo "missing binary: $BIN_PATH" >&2
@@ -27,13 +28,63 @@ if [ ! -f "$BIN_PATH" ]; then
exit 1
fi
+if [ "$(uname -s)" != "Darwin" ]; then
+ echo "macOS app bundles can only be built on macOS" >&2
+ exit 1
+fi
+
+if ! command -v xcrun >/dev/null 2>&1; then
+ echo "missing xcrun; install Xcode command line tools" >&2
+ exit 1
+fi
+
+if ! xcrun --sdk macosx --find swiftc >/dev/null 2>&1; then
+ echo "missing swiftc; install Xcode command line tools" >&2
+ exit 1
+fi
+
+if ! command -v sips >/dev/null 2>&1; then
+ echo "missing sips; cannot generate app icon" >&2
+ exit 1
+fi
+
+if ! command -v iconutil >/dev/null 2>&1; then
+ echo "missing iconutil; cannot generate app icon" >&2
+ exit 1
+fi
+
rm -rf "$APP_DIR"
mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
cp packaging/macos/Info.plist "$CONTENTS_DIR/Info.plist"
-cp packaging/macos/Stringcast "$MACOS_DIR/Stringcast"
-cp "$BIN_PATH" "$RESOURCES_DIR/stringcast"
+cp "$BIN_PATH" "$MACOS_DIR/Stringcast"
+xcrun --sdk macosx swiftc \
+ -module-cache-path "${TMPDIR:-/tmp}/stringcast-swift-module-cache" \
+ packaging/macos/StringcastMenu.swift \
+ -framework AppKit \
+ -o "$MACOS_DIR/StringcastMenu"
+
+if [ -f "$ICON_SOURCE" ]; then
+ ICONSET_DIR="$OUT_DIR/StringcastIcon.iconset"
+ rm -rf "$ICONSET_DIR"
+ mkdir -p "$ICONSET_DIR"
+ sips -z 16 16 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_16x16.png" >/dev/null
+ sips -z 32 32 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_16x16@2x.png" >/dev/null
+ sips -z 32 32 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_32x32.png" >/dev/null
+ sips -z 64 64 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_32x32@2x.png" >/dev/null
+ sips -z 128 128 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_128x128.png" >/dev/null
+ sips -z 256 256 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_128x128@2x.png" >/dev/null
+ sips -z 256 256 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_256x256.png" >/dev/null
+ sips -z 512 512 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_256x256@2x.png" >/dev/null
+ sips -z 512 512 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_512x512.png" >/dev/null
+ sips -z 1024 1024 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_512x512@2x.png" >/dev/null
+ iconutil -c icns "$ICONSET_DIR" -o "$RESOURCES_DIR/StringcastIcon.icns"
+ rm -rf "$ICONSET_DIR"
+else
+ echo "warning: icon source not found: $ICON_SOURCE" >&2
+ echo " save the app icon PNG there, or set STRINGCAST_ICON=/path/to/icon.png" >&2
+fi
-chmod +x "$MACOS_DIR/Stringcast" "$RESOURCES_DIR/stringcast"
+chmod +x "$MACOS_DIR/Stringcast" "$MACOS_DIR/StringcastMenu"
echo "Built $APP_DIR"
diff --git a/src/main.rs b/src/main.rs
index 304d058..7251f46 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -6,7 +6,7 @@ use stringcast::api::{
ApiClient, ApiClientConfig, KeyMaterialStore, KeyPool, ReqwestHttpTransport,
StaticKeyMaterialStore,
};
-use stringcast::input::{InputHook, RdevInputHook};
+use stringcast::input::{InputControllerOutcome, InputEvent, InputHook, RdevInputHook};
use stringcast::platform::{PermissionChecker, SystemPermissionChecker};
use stringcast::runtime::StringcastRuntime;
use stringcast::storage::{config_file_path, ApiKeyConfig, AppConfig, KeyringKeyMaterialStore};
@@ -29,6 +29,7 @@ fn run() -> Result<(), String> {
Some("set-model") => return set_model(&args[1..]),
Some("show-config") => return show_config_path(),
Some("check-permissions") => return check_permissions(),
+ Some("request-permissions") => return request_permissions(),
Some("add-key") => return add_key(&args[1..]),
Some("api-test") => return api_test(),
Some("run") | None => {}
@@ -45,7 +46,12 @@ fn run() -> Result<(), String> {
)
})?;
- preflight_permissions()?;
+ launch_app_menu_if_needed();
+ request_app_permissions_if_needed();
+
+ if !skip_permission_preflight() {
+ preflight_permissions()?;
+ }
let key_material = load_key_material(&config)?;
let mut runtime = StringcastRuntime::from_config(&config, key_material)
@@ -54,11 +60,25 @@ fn run() -> Result<(), String> {
println!("Stringcast running. Config: {}", config_path.display());
let mut hook = input_hook();
+ let log_events = log_events();
hook.run(move |event| {
+ if log_events {
+ eprintln!("Stringcast input event: {}", describe_input_event(&event));
+ }
+
let outcome = runtime.handle_event(event, Instant::now());
- if let Err(error) = outcome {
- eprintln!("Stringcast event error: {error:?}");
+ match outcome {
+ Ok(outcome) if log_events => {
+ eprintln!(
+ "Stringcast input outcome: {}",
+ describe_input_outcome(&outcome)
+ );
+ }
+ Ok(_) => {}
+ Err(error) => {
+ eprintln!("Stringcast event error: {error:?}");
+ }
}
})
.map_err(|error| format!("input hook error: {error:?}"))
@@ -86,6 +106,91 @@ fn preflight_permissions() -> Result<(), String> {
report.startup_error_message().map_or(Ok(()), Err)
}
+fn skip_permission_preflight() -> bool {
+ env::var("STRINGCAST_SKIP_PERMISSION_PREFLIGHT").is_ok_and(|value| value == "1")
+ || running_from_app_bundle()
+}
+
+fn log_events() -> bool {
+ env::var("STRINGCAST_LOG_EVENTS").is_ok_and(|value| value == "1")
+}
+
+fn describe_input_event(event: &InputEvent) -> String {
+ match event {
+ InputEvent::Text(text) => format!("Text(len={})", text.chars().count()),
+ InputEvent::Backspace => "Backspace".to_string(),
+ InputEvent::Delete => "Delete".to_string(),
+ InputEvent::Enter => "Enter".to_string(),
+ InputEvent::Escape => "Escape".to_string(),
+ InputEvent::Tab => "Tab".to_string(),
+ InputEvent::Navigation(key) => format!("Navigation({key:?})"),
+ InputEvent::MouseButton => "MouseButton".to_string(),
+ InputEvent::Shortcut(shortcut) => format!("Shortcut({shortcut:?})"),
+ InputEvent::FocusChanged => "FocusChanged".to_string(),
+ InputEvent::SleepOrLock => "SleepOrLock".to_string(),
+ }
+}
+
+fn describe_input_outcome(outcome: &InputControllerOutcome) -> String {
+ match outcome {
+ InputControllerOutcome::IgnoredSynthetic => "IgnoredSynthetic".to_string(),
+ InputControllerOutcome::BufferUpdated(buffer) => {
+ format!("BufferUpdated(len={})", buffer.chars().count())
+ }
+ InputControllerOutcome::BufferCleared => "BufferCleared".to_string(),
+ InputControllerOutcome::Pipeline(outcome) => format!("Pipeline({outcome:?})"),
+ }
+}
+
+#[cfg(target_os = "macos")]
+fn running_from_app_bundle() -> bool {
+ env::current_exe()
+ .ok()
+ .and_then(|path| path.to_str().map(str::to_string))
+ .is_some_and(|path| path.contains(".app/Contents/MacOS/"))
+}
+
+#[cfg(not(target_os = "macos"))]
+fn running_from_app_bundle() -> bool {
+ false
+}
+
+#[cfg(target_os = "macos")]
+fn launch_app_menu_if_needed() {
+ if !running_from_app_bundle() || env::var("STRINGCAST_APP_MENU_DISABLED").is_ok() {
+ return;
+ }
+
+ let Ok(exe) = env::current_exe() else {
+ return;
+ };
+ let Some(macos_dir) = exe.parent() else {
+ return;
+ };
+
+ let menu_exe = macos_dir.join("StringcastMenu");
+ if !menu_exe.exists() {
+ return;
+ }
+
+ let _ = std::process::Command::new(menu_exe)
+ .env("STRINGCAST_APP_RUNTIME_PID", std::process::id().to_string())
+ .spawn();
+}
+
+#[cfg(not(target_os = "macos"))]
+fn launch_app_menu_if_needed() {}
+
+#[cfg(target_os = "macos")]
+fn request_app_permissions_if_needed() {
+ if running_from_app_bundle() {
+ let _ = stringcast::platform::request_accessibility_permission();
+ }
+}
+
+#[cfg(not(target_os = "macos"))]
+fn request_app_permissions_if_needed() {}
+
fn check_permissions() -> Result<(), String> {
let report = SystemPermissionChecker::default().permission_report();
println!("Accessibility: {:?}", report.accessibility);
@@ -96,6 +201,24 @@ fn check_permissions() -> Result<(), String> {
Ok(())
}
+#[cfg(target_os = "macos")]
+fn request_permissions() -> Result<(), String> {
+ let trusted = stringcast::platform::request_accessibility_permission();
+ let report = SystemPermissionChecker::default().permission_report();
+ println!("Accessibility prompt requested: {trusted}");
+ println!("Accessibility: {:?}", report.accessibility);
+ println!("Input Monitoring: {:?}", report.input_monitoring);
+ println!(
+ "If macOS opens Privacy & Security, enable Stringcast or stringcast, then restart Stringcast."
+ );
+ Ok(())
+}
+
+#[cfg(not(target_os = "macos"))]
+fn request_permissions() -> Result<(), String> {
+ check_permissions()
+}
+
fn init_config() -> Result<(), String> {
let config_path =
config_file_path().map_err(|error| format!("config path error: {error:?}"))?;
@@ -347,6 +470,7 @@ fn usage() -> &'static str {
stringcast set-model
stringcast show-config
stringcast check-permissions
+ stringcast request-permissions
stringcast api-test
STRINGCAST_API_KEY= stringcast add-key [alias]"
}
diff --git a/src/platform/macos.rs b/src/platform/macos.rs
index 217935f..0063b0a 100644
--- a/src/platform/macos.rs
+++ b/src/platform/macos.rs
@@ -3,6 +3,7 @@ use super::{
PlatformContextError,
};
use std::process::Command;
+use std::{ffi::c_void, ptr};
#[derive(Debug, Clone, Default)]
pub struct MacOsForegroundAppProvider;
@@ -98,10 +99,47 @@ fn accessibility_trusted() -> bool {
unsafe { AXIsProcessTrusted() }
}
+pub fn request_accessibility_permission() -> bool {
+ unsafe {
+ let keys = [kAXTrustedCheckOptionPrompt];
+ let values = [kCFBooleanTrue];
+ let options = CFDictionaryCreate(
+ ptr::null(),
+ keys.as_ptr(),
+ values.as_ptr(),
+ 1,
+ ptr::null(),
+ ptr::null(),
+ );
+
+ let trusted = AXIsProcessTrustedWithOptions(options);
+ if !options.is_null() {
+ CFRelease(options);
+ }
+ trusted
+ }
+}
+
#[link(name = "ApplicationServices", kind = "framework")]
unsafe extern "C" {
fn IsSecureEventInputEnabled() -> bool;
fn AXIsProcessTrusted() -> bool;
+ static kAXTrustedCheckOptionPrompt: *const c_void;
+ fn AXIsProcessTrustedWithOptions(options: *const c_void) -> bool;
+}
+
+#[link(name = "CoreFoundation", kind = "framework")]
+unsafe extern "C" {
+ static kCFBooleanTrue: *const c_void;
+ fn CFDictionaryCreate(
+ allocator: *const c_void,
+ keys: *const *const c_void,
+ values: *const *const c_void,
+ num_values: isize,
+ key_callbacks: *const c_void,
+ value_callbacks: *const c_void,
+ ) -> *const c_void;
+ fn CFRelease(cf: *const c_void);
}
#[cfg(test)]
diff --git a/src/platform/mod.rs b/src/platform/mod.rs
index ca2dc97..fbceaf8 100644
--- a/src/platform/mod.rs
+++ b/src/platform/mod.rs
@@ -7,7 +7,9 @@ mod macos;
mod windows;
#[cfg(target_os = "macos")]
-pub use macos::{MacOsForegroundAppProvider, MacOsPermissionChecker};
+pub use macos::{
+ request_accessibility_permission, MacOsForegroundAppProvider, MacOsPermissionChecker,
+};
#[cfg(target_os = "windows")]
pub use windows::WindowsForegroundAppProvider;