Advanced Usage
View SourceThis guide covers advanced topics for power users: performance optimization, telemetry integration, retry strategies, custom adapters, and the Objective-C bridge.
Table of Contents
- Performance Optimization
- Telemetry Integration
- Retry Strategies
- Custom Adapters
- Objective-C Bridge (JXA)
Performance Optimization
Understanding Execution Time
macOS automation has three performance components:
# 1. Script Building (~microseconds) - negligible
script = Script.tell("Finder", ["activate"])
# 2. osascript Launch (~50-200ms) - fixed overhead
# 3. Actual Script Execution (varies) - your bottleneck
ExMacOSControl.run_applescript(script) # Total: component 2 + 3Key Insight: The osascript launch overhead is fixed. Optimize script execution, not script building.
Timeout Tuning
Default timeouts vary by operation. Tune them based on your needs:
# Default timeout (usually 30 seconds)
{:ok, result} = ExMacOSControl.run_applescript(script)
# Quick operations - fail fast
{:ok, result} = ExMacOSControl.run_applescript(script, timeout: 5_000)
# Long operations - be patient
{:ok, result} = ExMacOSControl.run_applescript(script, timeout: 120_000)
# Infinite timeout (use with caution!)
{:ok, result} = ExMacOSControl.run_applescript(script, timeout: :infinity)Recommendations:
- UI Automation: 10-30 seconds (depends on app responsiveness)
- File Operations: 5-10 seconds
- Network Operations: 30-60 seconds
- Batch Processing: 60-300 seconds
Batch Operations
Group multiple operations into a single AppleScript:
# ❌ Slow: Multiple osascript calls
for file <- files do
script = Script.tell("Finder", [Script.cmd("open", file)])
ExMacOSControl.run_applescript(script)
# Each call: ~50-200ms overhead
end
# ✅ Fast: Single osascript call
commands = Enum.map(files, &Script.cmd("open", &1))
script = Script.tell("Finder", commands)
ExMacOSControl.run_applescript(script)
# One call: ~50-200ms overhead totalPerformance Gain: ~10x faster for 10 files
Caching Compiled Scripts
Pre-compile static scripts at module load time:
defmodule FastAutomation do
@activate_finder Script.tell("Finder", ["activate"])
@activate_safari Script.tell("Safari", ["activate"])
def activate_finder do
ExMacOSControl.run_applescript(@activate_finder)
end
def activate_safari do
ExMacOSControl.run_applescript(@activate_safari)
end
endBenefit: Eliminates script building time (though it's already negligible)
Concurrent Execution
Run independent operations in parallel:
# ❌ Sequential: 6 seconds total
ExMacOSControl.run_applescript(script1) # 3 seconds
ExMacOSControl.run_applescript(script2) # 3 seconds
# ✅ Parallel: 3 seconds total
task1 = Task.async(fn -> ExMacOSControl.run_applescript(script1) end)
task2 = Task.async(fn -> ExMacOSControl.run_applescript(script2) end)
{:ok, result1} = Task.await(task1, :infinity)
{:ok, result2} = Task.await(task2, :infinity)Warning: Don't parallelize operations on the same app - they may conflict.
Reducing Wait Times
Use polling instead of fixed sleeps:
# ❌ Always waits 5 seconds
Safari.open_url(url)
Process.sleep(5000)
Safari.execute_javascript("document.title")
# ✅ Polls until ready (usually faster)
Safari.open_url(url)
wait_until_loaded()
Safari.execute_javascript("document.title")
defp wait_until_loaded(max_attempts \\ 50) do
Enum.reduce_while(1..max_attempts, nil, fn attempt, _ ->
case Safari.execute_javascript("document.readyState") do
{:ok, "complete"} -> {:halt, :ok}
_ ->
Process.sleep(100)
{:cont, nil}
end
end)
endTelemetry Integration
ExMacOSControl emits :telemetry events for observability.
Available Events
# AppleScript execution lifecycle
[:ex_macos_control, :applescript, :start] # When execution begins
[:ex_macos_control, :applescript, :stop] # When execution succeeds
[:ex_macos_control, :applescript, :exception] # When execution fails
# Retry logic lifecycle
[:ex_macos_control, :retry, :start] # Retry begins
[:ex_macos_control, :retry, :attempt] # Each retry attempt
[:ex_macos_control, :retry, :stop] # Retry succeeds
[:ex_macos_control, :retry, :error] # All retries exhaustedBasic Telemetry Setup
# In your application.ex
def start(_type, _args) do
:telemetry.attach_many(
"ex-macos-control-telemetry",
[
[:ex_macos_control, :applescript, :start],
[:ex_macos_control, :applescript, :stop],
[:ex_macos_control, :applescript, :exception]
],
&MyApp.Telemetry.handle_event/4,
nil
)
# ... rest of your supervision tree
endLogging Handler
defmodule MyApp.Telemetry do
require Logger
def handle_event([:ex_macos_control, :applescript, :start], _measurements, metadata, _config) do
Logger.debug("Executing AppleScript: #{String.slice(metadata.script, 0, 100)}...")
end
def handle_event([:ex_macos_control, :applescript, :stop], measurements, metadata, _config) do
duration_ms = System.convert_time_unit(measurements.duration, :native, :millisecond)
Logger.info("AppleScript completed in #{duration_ms}ms")
end
def handle_event([:ex_macos_control, :applescript, :exception], _measurements, metadata, _config) do
Logger.error("AppleScript failed: #{inspect(metadata.error)}")
end
def handle_event(_event, _measurements, _metadata, _config) do
:ok
end
endPerformance Monitoring
Track slow operations:
defmodule MyApp.PerformanceMonitor do
require Logger
def handle_event([:ex_macos_control, :applescript, :stop], measurements, metadata, _config) do
duration_ms = System.convert_time_unit(measurements.duration, :native, :millisecond)
if duration_ms > 5_000 do
Logger.warning("""
Slow AppleScript detected!
Duration: #{duration_ms}ms
Script: #{String.slice(metadata.script, 0, 200)}
Timeout: #{metadata.timeout}ms
""")
end
end
def handle_event(_, _, _, _), do: :ok
endMetrics Collection
Integrate with your metrics system:
defmodule MyApp.Metrics do
# Using Telemetry.Metrics (example)
def metrics do
[
# Count AppleScript executions
Telemetry.Metrics.counter("ex_macos_control.applescript.count",
event_name: [:ex_macos_control, :applescript, :stop]
),
# Track execution duration
Telemetry.Metrics.distribution("ex_macos_control.applescript.duration",
event_name: [:ex_macos_control, :applescript, :stop],
measurement: :duration,
unit: {:native, :millisecond}
),
# Count failures
Telemetry.Metrics.counter("ex_macos_control.applescript.errors",
event_name: [:ex_macos_control, :applescript, :exception]
),
# Track retry attempts
Telemetry.Metrics.sum("ex_macos_control.retry.total_attempts",
event_name: [:ex_macos_control, :retry, :attempt],
measurement: :attempt
)
]
end
endEvent Metadata Reference
AppleScript Events:
# :start event metadata
%{
command: "osascript",
script: "tell application...", # Full script
timeout: 30_000
}
# :stop event measurements & metadata
measurements: %{
duration: 1_234_567, # Native time units
script_length: 156 # Character count
}
metadata: %{
command: "osascript",
script: "tell application...",
timeout: 30_000,
result_type: :ok,
output_length: 42
}
# :exception event metadata
%{
command: "osascript",
script: "tell application...",
timeout: 30_000,
error: %ExMacOSControl.Error{type: :timeout, ...}
}Retry Events:
# :attempt event metadata
%{
attempt: 2, # Current attempt (1-indexed)
max_attempts: 3,
backoff: :exponential,
sleep_time: 400 # ms slept before this attempt
}Retry Strategies
Built-in Retry
ExMacOSControl includes automatic retry for timeout errors:
alias ExMacOSControl.Retry
# Default: 3 attempts, exponential backoff
{:ok, result} = Retry.with_retry(fn ->
ExMacOSControl.Safari.execute_javascript("document.title")
end)Backoff Strategies
Exponential Backoff (default):
# Retry delays: 200ms, 400ms, 800ms, 1600ms...
# Formula: 2^attempt * 100ms
Retry.with_retry(fn ->
operation()
end, backoff: :exponential, max_attempts: 5)Linear Backoff:
# Retry delays: 1000ms, 1000ms, 1000ms...
Retry.with_retry(fn ->
operation()
end, backoff: :linear, max_attempts: 5)When to Retry
✅ Retry timeout errors - may succeed on subsequent attempts ✅ UI automation - apps may be busy ✅ Network-dependent operations - transient failures
❌ Don't retry syntax errors - they won't fix themselves ❌ Don't retry permission errors - user intervention required ❌ Don't retry not found errors - resource still won't exist
Custom Retry Logic
Build your own retry wrapper:
defmodule CustomRetry do
require Logger
def with_custom_retry(fun, opts \\ []) do
max_attempts = Keyword.get(opts, :max_attempts, 3)
delay = Keyword.get(opts, :delay, 1000)
retry_on = Keyword.get(opts, :retry_on, [:timeout])
do_retry(fun, 1, max_attempts, delay, retry_on)
end
defp do_retry(fun, attempt, max_attempts, delay, retry_on) do
case fun.() do
{:ok, result} ->
{:ok, result}
{:error, %{type: type}} = error when attempt < max_attempts ->
if type in retry_on do
Logger.warn("Attempt #{attempt} failed with #{type}, retrying in #{delay}ms...")
Process.sleep(delay)
do_retry(fun, attempt + 1, max_attempts, delay, retry_on)
else
Logger.error("Non-retryable error: #{type}")
error
end
error ->
Logger.error("All #{max_attempts} attempts failed")
error
end
end
end
# Usage: Retry on :timeout and :execution_error
CustomRetry.with_custom_retry(fn ->
Safari.open_url(url)
end, max_attempts: 5, delay: 2000, retry_on: [:timeout, :execution_error])Custom Adapters
The adapter pattern allows you to mock, wrap, or replace the default osascript implementation.
Why Custom Adapters?
- Testing: Mock macOS automation in tests
- Logging: Add detailed logging around all operations
- Caching: Cache script results
- Rate Limiting: Prevent overwhelming macOS
- Alternative Backends: Use different execution methods
Creating a Custom Adapter
defmodule MyApp.LoggingAdapter do
@behaviour ExMacOSControl.Adapter
require Logger
@impl true
def run_applescript(script, opts \\ []) do
Logger.info("Running AppleScript: #{String.slice(script, 0, 100)}...")
start_time = System.monotonic_time()
result = ExMacOSControl.OSAScriptAdapter.run_applescript(script, opts)
duration = System.convert_time_unit(
System.monotonic_time() - start_time,
:native,
:millisecond
)
Logger.info("Completed in #{duration}ms: #{inspect(result)}")
result
end
@impl true
def run_javascript(script, opts \\ []) do
Logger.info("Running JavaScript: #{String.slice(script, 0, 100)}...")
ExMacOSControl.OSAScriptAdapter.run_javascript(script, opts)
end
endUsing a Custom Adapter
Configure in config.exs:
# config/config.exs
config :ex_macos_control, :adapter, MyApp.LoggingAdapter
# Or for testing only
# config/test.exs
config :ex_macos_control, :adapter, MyApp.MockAdapterRate-Limiting Adapter
Prevent overwhelming macOS with too many rapid operations:
defmodule MyApp.RateLimitedAdapter do
@behaviour ExMacOSControl.Adapter
use GenServer
# Start the GenServer
def start_link(_opts) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
# Adapter implementation
@impl true
def run_applescript(script, opts \\ []) do
GenServer.call(__MODULE__, {:run_applescript, script, opts}, :infinity)
end
@impl true
def run_javascript(script, opts \\ []) do
GenServer.call(__MODULE__, {:run_javascript, script, opts}, :infinity)
end
# GenServer callbacks
@impl true
def init(_) do
{:ok, %{last_execution: 0, min_interval: 100}} # 100ms between calls
end
@impl true
def handle_call({:run_applescript, script, opts}, _from, state) do
state = enforce_rate_limit(state)
result = ExMacOSControl.OSAScriptAdapter.run_applescript(script, opts)
{:reply, result, %{state | last_execution: System.monotonic_time(:millisecond)}}
end
@impl true
def handle_call({:run_javascript, script, opts}, _from, state) do
state = enforce_rate_limit(state)
result = ExMacOSControl.OSAScriptAdapter.run_javascript(script, opts)
{:reply, result, %{state | last_execution: System.monotonic_time(:millisecond)}}
end
defp enforce_rate_limit(state) do
now = System.monotonic_time(:millisecond)
elapsed = now - state.last_execution
if elapsed < state.min_interval do
Process.sleep(state.min_interval - elapsed)
end
state
end
endObjective-C Bridge (JXA)
JavaScript for Automation (JXA) provides access to Objective-C APIs via the ObjC bridge.
Basic ObjC Bridge Usage
script = """
ObjC.import('Foundation')
// Create NSString
var str = $.NSString.alloc.initWithUTF8String('Hello from ObjC!')
str.description.js
"""
{:ok, result} = ExMacOSControl.run_javascript(script)
# => {:ok, "Hello from ObjC!"}Common Patterns
Working with NSFileManager:
script = """
ObjC.import('Foundation')
var fileManager = $.NSFileManager.defaultManager
var homePath = fileManager.homeDirectoryForCurrentUser.path.js
homePath
"""
{:ok, home} = ExMacOSControl.run_javascript(script)
# => {:ok, "/Users/username"}Working with NSWorkspace:
script = """
ObjC.import('AppKit')
var workspace = $.NSWorkspace.sharedWorkspace
var apps = workspace.runningApplications.js
apps.map(app => app.localizedName.js)
"""
{:ok, apps} = ExMacOSControl.run_javascript(script)
# => {:ok, ["Finder", "Safari", "Terminal", ...]}Working with Notifications:
script = """
ObjC.import('Foundation')
var center = $.NSNotificationCenter.defaultCenter
center.postNotificationNameObject('MyCustomNotification', 'SomeData')
'Notification sent'
"""
ExMacOSControl.run_javascript(script)ObjC vs AppleScript
Use ObjC When:
- Accessing low-level macOS APIs
- Need Foundation/AppKit classes
- Working with C APIs
- Need better performance for data processing
Use AppleScript When:
- Controlling applications
- UI automation
- Better app-specific support
- Following existing examples
Advanced: Calling Swift/ObjC from JXA
You can bridge to custom frameworks:
script = """
ObjC.import('Foundation')
ObjC.import('MyCustomFramework') // Your custom framework
var myObject = $.MyCustomClass.alloc.init
myObject.doSomethingWith('data')
"""Type Conversion Reference
| JavaScript | Objective-C | Note |
|---|---|---|
"string" | NSString | Automatic |
123 | NSNumber | Automatic |
true/false | NSNumber | Automatic |
[] | NSArray | Automatic |
{} | NSDictionary | Automatic |
.js suffix | Unwrap to JS | Manual |
Example:
// ObjC NSString to JS string
var nsString = $.NSString.alloc.initWithUTF8String('hello')
var jsString = nsString.js // Convert to JavaScript string
// JS array to ObjC NSArray (automatic)
var jsArray = [1, 2, 3]
$.NSArray.arrayWithArray(jsArray)Best Practices
Performance
- Batch operations into single scripts
- Use appropriate timeouts - not too short, not too long
- Run independent operations in parallel
- Poll instead of fixed waits when possible
Telemetry
- Monitor slow operations (> 5 seconds)
- Track failure rates by error type
- Alert on spikes in execution time
- Log retry attempts for debugging
Retry
- Only retry transient errors (timeouts)
- Use exponential backoff for most cases
- Limit max attempts (3-5 is usually enough)
- Log retry attempts for visibility
Adapters
- Keep adapters thin - delegate to default adapter
- Test custom adapters thoroughly
- Document adapter behavior clearly
- Consider rate limiting in production
ObjC Bridge
- Prefer AppleScript for app control
- Use ObjC for low-level APIs only
- Test extensively - ObjC errors can crash scripts
- Document type conversions in comments
Further Reading
- Performance Guide - Detailed performance tuning
- Common Patterns - Real-world examples
- Telemetry Documentation - Official telemetry docs
- JXA Release Notes - Apple's JXA reference