AppleScript Object Specifier for Shared Cocoa Objects
Assign stable UUIDs, expose an app-level resolver, and return NSUniqueIDSpecifier so AppleScript recognizes shared Cocoa objects as identical. Fast and stable.
AppleScript objectSpecifier for objects appearing as elements in multiple scriptable classes
I am making an Objective-C app scriptable. I have a custom scriptable class A used as <element> by several other scriptable classes (e.g., B, C). Some instances of A are shared across these classes:
@interface A : NSObject
// ...
@end
@interface B : NSWindow
@property (strong, nonatomic) NSArray<A*> *someInstancesOfA;
@end
@interface C : NSWindow
@property (strong, nonatomic) NSArray<A*> *someInstancesOfA;
@end
There is no central dictionary accessing all A instances. Creating one would require iterating through all windows, collecting arrays, and deduplicating (e.g., with NSSet), which is inefficient.
How can I enable AppleScript to uniquely identify these shared A instances when returned from different classes (B, C, etc.)?
NSUniqueIDSpecifier won’t work, as it needs a central accessor—otherwise, the same A instance gets different IDs from different paths, so scripts can’t recognize them as identical.
What is the recommended objectSpecifier for this common scriptable app scenario? Documentation and examples don’t address it.
Assign a stable unique identifier (e.g., an NSUUID string) to every A instance, keep a lightweight app-level registry (weak references) that maps that ID to the instance, and have A’s -objectSpecifier return an NSUniqueIDSpecifier that points at that app-level accessor. In Cocoa scripting this produces a canonical AppleScript object specifier so the same shared A is recognized identically whether it comes from B, C, or any other container.
Contents
- AppleScript object specifier recommendation
- Cocoa scripting: implement stable unique ID and lightweight registry
- Fallbacks: whose specifier and custom specifiers
- Example: minimal Objective‑C implementation
- Best practices & pitfalls
- Sources
- Conclusion
AppleScript object specifier recommendation
Short version: give each A a stable, script-visible ID and expose an application-level way to resolve that ID; then return an NSUniqueIDSpecifier from A::objectSpecifier. That’s the idiomatic Cocoa scripting pattern for objects that can appear in multiple containers.
Why this works: AppleScript (the AEOM/Cocoa scripting model) needs a consistent path or a consistent unique-id resolution to treat two references as the same object. If A’s -objectSpecifier uses a container path (e.g., B’s elements or C’s elements) you’ll get different specifiers depending on which container you were returned from — so AppleScript will treat them as different. NSUniqueIDSpecifier gives you a canonical reference, but it requires a container that can resolve the unique ID. The lightweight registry + app accessor gives you that resolver without repeatedly walking all windows/collections.
See the AppleScript / AEOM background for how Cocoa maps object specifiers and unique-IDs in scriptable apps: AppleScript - Wikipedia and Apple event - Wikipedia.
Cocoa scripting: implement stable unique ID and lightweight registry
Steps (high level)
- Add a stable script-visible ID to A (NSUUID string is fine). Expose it in your sdef or through automatic scripting properties so AppleScript can read it if needed.
- Maintain a global registry (singleton) that maps ID → weak A*. Register A in init and unregister on dealloc. Using an NSMapTable strong keys → weak values is common and efficient.
- Expose a scriptable app-level accessor (the container for NSUniqueIDSpecifier) that looks up an A by that ID using the registry.
- Override -objectSpecifier on A to return an NSUniqueIDSpecifier whose containerClassDescription/key point at the app-level element name you defined in your sdef and whose uniqueID is A’s ID.
Quick rationale: NSUniqueIDSpecifier resolves by asking the declared container how to return the object for the unique ID. If the container uses your registry lookup, resolution is O(1) (hash lookup) rather than scanning windows each time.
Practical notes:
- The “container class description” used when creating the NSUniqueIDSpecifier should be the class you declare as the scriptable container in your sdef (often your application or app controller class).
- If scripts are stored/persisted across launches, persist the UUID (or generate stable IDs tied to saved documents). If IDs are only needed for a single run, ephemeral UUIDs are fine.
- For details on Cocoa scripting class descriptions and specifiers, review Cocoa scripting documentation and examples; background reading: Cocoa (API) - Wikipedia.
Fallbacks: whose specifier and custom specifiers
If you can’t or won’t maintain a registry, two other approaches exist — both with tradeoffs.
- NSScriptWhoseSpecifier (the “whose” query)
- AppleScript can ask for “first a whose uuid is ‘…’” (a whose uuid is “…”). This maps to Cocoa’s whose/predicate machinery and uses NSPredicate to filter the container’s elements.
- Pros: no central registry required; easier to implement quickly.
- Cons: resolution requires iterating/filtering the relevant container(s) at resolution time, which can be slow and fragile if you have many containers (B, C, windows, documents).
- Custom NSScriptObjectSpecifier subclass
- You can implement a bespoke specifier class that stores your unique ID and resolves directly via some resolver API you provide.
- Pros: full control over resolution semantics.
- Cons: more work; you still need a resolver (so effectively you re-implement the registry concept), and you must integrate carefully with Cocoa scripting resolution lifecycle.
In most real apps the registry + NSUniqueIDSpecifier pattern is the simplest, fastest, and most maintainable.
For predicate/whose details see how NSPredicate is used with Cocoa scripting: NSPredicate - NSHipster.
Example: minimal Objective‑C implementation
The code below is a minimal, practical illustration. It’s intentionally small; adapt names / sdef keys to your app.
ARegistry.h
#import <Foundation/Foundation.h>
@class A;
@interface ARegistry : NSObject
+ (instancetype)sharedRegistry;
- (void)registerA:(A *)object;
- (void)unregisterA:(A *)object;
- (A *)aForUniqueID:(NSString *)uid;
@end
ARegistry.m
#import "ARegistry.h"
@interface ARegistry ()
@property (nonatomic, strong) NSMapTable<NSString*, id> *map; // strong keys, weak values
@end
@implementation ARegistry
+ (instancetype)sharedRegistry {
static ARegistry *s;
static dispatch_once_t once;
dispatch_once(&once, ^{
s = [self new];
});
return s;
}
- (instancetype)init {
if ((self = [super init])) {
_map = [NSMapTable strongToWeakObjectsMapTable];
}
return self;
}
- (void)registerA:(id)object {
NSString *uid = [object valueForKey:@"scriptUniqueID"];
if (!uid) return;
@synchronized(self) { [self.map setObject:object forKey:uid]; }
}
- (void)unregisterA:(id)object {
NSString *uid = [object valueForKey:@"scriptUniqueID"];
if (!uid) return;
@synchronized(self) { [self.map removeObjectForKey:uid]; }
}
- (id)aForUniqueID:(NSString *)uid {
@synchronized(self) { return [self.map objectForKey:uid]; }
}
@end
A.h / A.m (snippets)
@interface A : NSObject
@property (nonatomic, copy) NSString *scriptUniqueID; // exposed to scripting
@end
@implementation A
- (instancetype)init {
if ((self = [super init])) {
_scriptUniqueID = [[NSUUID UUID] UUIDString];
[[ARegistry sharedRegistry] registerA:self];
}
return self;
}
- (void)dealloc {
[[ARegistry sharedRegistry] unregisterA:self];
}
// Return a canonical specifier that points at the app-level collection.
// Replace AppController with the class you declared in your sdef as the container
// and "as" with the element name you used there.
- (NSScriptObjectSpecifier *)objectSpecifier {
NSScriptClassDescription *containerDesc =
(NSScriptClassDescription *)[NSScriptClassDescription classDescriptionForClass:[AppController class]];
return [[NSUniqueIDSpecifier alloc] initWithContainerClassDescription:containerDesc
containerSpecifier:nil
key:@"as"
uniqueID:self.scriptUniqueID];
}
@end
AppController (scriptable app root) — add a resolver method exposed in sdef:
- (A *)aWithUniqueID:(NSString *)uid {
return [[ARegistry sharedRegistry] aForUniqueID:uid];
}
AppleScript usage example:
tell application "MyApp"
set sharedA to a id "E7F7A8B0-..."
-- or: set sharedA to first a whose scriptUniqueID is "E7F7A8B0-..."
end tell
If your sdef names the element “a” (plural “as”) and exposes the scriptUniqueID property, NSUniqueIDSpecifier will generate the AppleScript a id "UUID" form.
Best practices & pitfalls
- Use an NSUUID string (or other stable token) rather than pointer values. Pointers change and aren’t script-friendly.
- Use NSMapTable strong keys → weak values to avoid retain cycles; unregister on dealloc.
- Thread-safety: guard registry accesses (simple @synchronized or a concurrent queue with barrier writes).
- Persistence: if scripts will be saved and run later, persist IDs to disk (or recreate a stable mapping tied to document identity).
- sdef: declare the element and the unique-id property so AppleScript editors show meaningful names. That helps discoverability in Script Editor.
- Debugging: log [myA objectSpecifier] — it reveals the specifier class (NSUniqueIDSpecifier vs. container specifier) and the uniqueID it contains.
- Performance: registry lookups are O(1). The “whose” approach is O(n) across whatever container(s) you search — fine for small sets, painful for many windows/objects.
- Security: be mindful if exposing globally addressable objects could leak sensitive state to AppleScript.
A discussion of the scripting architecture and why you need a resolver appears in the general AEOM/Cocoa scripting docs — useful context: Apple event - Wikipedia and Cocoa scripting background: AppleScript - Wikipedia.
Sources
- https://en.wikipedia.org/wiki/AppleScript
- https://en.wikipedia.org/wiki/Cocoa_(API)
- https://en.wikipedia.org/wiki/Apple_event
- https://nshipster.com/identifiable/
- https://nshipster.com/nspredicate/
- https://en.wikipedia.org/wiki/AppleScript_Editor
- https://redcanary.com/blog/threat-detection/mac-application-bundles/
- https://matthewcassinelli.com/shortcuts-applescript-commands/
Conclusion
Make A’s identity canonical: give each instance a stable script-visible ID, register instances in a lightweight app-level registry, and have A return an NSUniqueIDSpecifier that points at the app-level accessor. That pattern is the practical Cocoa scripting solution so AppleScript sees the same shared A regardless of which container returned it; it’s fast, predictable, and play nicely with Script Editor and saved scripts.