feat: add AppleScript support for external control#82
feat: add AppleScript support for external control#82sozercan merged 4 commits intosozercan:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds AppleScript support to Kaset, enabling external control through automation tools like Raycast and Alfred. The implementation includes 12 commands for controlling playback, volume, shuffle, repeat modes, and track ratings.
Changes:
- Adds AppleScript scripting definition file (Kaset.sdef) with 12 commands for player control
- Implements ScriptCommands.swift with NSScriptCommand subclasses for each AppleScript command
- Introduces static shared instance pattern for PlayerService to enable AppleScript access
- Updates Info.plist to enable AppleScript support and reference the SDEF file
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 16 comments.
Show a summary per file
| File | Description |
|---|---|
| Kaset.xcodeproj/project.pbxproj | Adds ScriptCommands.swift and Kaset.sdef to build configuration |
| Core/Services/Scripting/ScriptCommands.swift | Implements 12 NSScriptCommand subclasses for AppleScript integration |
| Core/Services/Player/PlayerService.swift | Adds static shared instance for AppleScript access |
| App/KasetApp.swift | Initializes PlayerService.shared and moves appDelegate.playerService assignment to onAppear |
| App/Kaset.sdef | Defines AppleScript terminology and command signatures |
| App/Info.plist | Enables AppleScript support and references SDEF file |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| Task { @MainActor in | ||
| guard let playerService = PlayerService.shared else { return } | ||
| await playerService.pause() | ||
| } |
There was a problem hiding this comment.
AppleScript commands execute synchronously and the caller expects the command to complete before returning. Creating an unstructured Task here means the command will return immediately before the async work completes, potentially causing the AppleScript to proceed before the action is finished. Consider using MainActor.assumeIsolated (similar to GetPlayerInfoCommand) or ensuring synchronous execution if PlayerService methods need to be awaited.
| Task { @MainActor in | ||
| guard let playerService = PlayerService.shared else { return } | ||
| await playerService.next() | ||
| } |
There was a problem hiding this comment.
AppleScript commands execute synchronously and the caller expects the command to complete before returning. Creating an unstructured Task here means the command will return immediately before the async work completes, potentially causing the AppleScript to proceed before the action is finished. Consider using MainActor.assumeIsolated (similar to GetPlayerInfoCommand) or ensuring synchronous execution if PlayerService methods need to be awaited.
| Task { @MainActor in | ||
| guard let playerService = PlayerService.shared else { return } | ||
| playerService.cycleRepeatMode() | ||
| } |
There was a problem hiding this comment.
While cycleRepeatMode is not async, creating an unstructured Task when calling it is unnecessary and creates potential timing issues. Since PlayerService is marked @mainactor and the task runs on MainActor, you can use MainActor.assumeIsolated to call this method synchronously, similar to how GetPlayerInfoCommand handles its logic. This ensures the command completes before returning to AppleScript.
| Task { @MainActor in | |
| guard let playerService = PlayerService.shared else { return } | |
| playerService.cycleRepeatMode() | |
| } | |
| guard let playerService = PlayerService.shared else { | |
| return nil | |
| } | |
| MainActor.assumeIsolated { | |
| playerService.cycleRepeatMode() | |
| } |
| Task { @MainActor in | ||
| guard let playerService = PlayerService.shared else { return } | ||
| playerService.dislikeCurrentTrack() | ||
| } | ||
| return nil |
There was a problem hiding this comment.
While dislikeCurrentTrack is not async, creating an unstructured Task when calling it is unnecessary and creates potential timing issues. Since PlayerService is marked @mainactor and the task runs on MainActor, you can use MainActor.assumeIsolated to call this method synchronously, similar to how GetPlayerInfoCommand handles its logic. This ensures the command completes before returning to AppleScript.
| <key>SUAllowsAutomaticUpdates</key> | ||
| <true/> | ||
|
|
||
| <!-- AppleScript Support --> |
There was a problem hiding this comment.
The XML comments use non-standard syntax for plist files. Plist files typically don't use XML comments within the dictionary. While this may work, consider using standard plist structure without inline comments, or document the AppleScript support in a separate documentation file.
| <!-- AppleScript Support --> |
| Task { @MainActor in | ||
| guard let playerService = PlayerService.shared else { return } | ||
| await playerService.resume() | ||
| } |
There was a problem hiding this comment.
AppleScript commands execute synchronously and the caller expects the command to complete before returning. Creating an unstructured Task here means the command will return immediately before the async work completes, potentially causing the AppleScript to proceed before the action is finished. Consider using MainActor.assumeIsolated (similar to GetPlayerInfoCommand) or ensuring synchronous execution if PlayerService methods need to be awaited.
| Task { @MainActor in | ||
| guard let playerService = PlayerService.shared else { return } | ||
| await playerService.setVolume(normalizedVolume) | ||
| } | ||
| return nil |
There was a problem hiding this comment.
AppleScript commands execute synchronously and the caller expects the command to complete before returning. Creating an unstructured Task here means the command will return immediately before the async work completes, potentially causing the AppleScript to proceed before the action is finished. Consider using MainActor.assumeIsolated (similar to GetPlayerInfoCommand) or ensuring synchronous execution if PlayerService methods need to be awaited.
| Task { @MainActor in | ||
| guard let playerService = PlayerService.shared else { return } | ||
| await playerService.toggleMute() | ||
| } |
There was a problem hiding this comment.
AppleScript commands execute synchronously and the caller expects the command to complete before returning. Creating an unstructured Task here means the command will return immediately before the async work completes, potentially causing the AppleScript to proceed before the action is finished. Consider using MainActor.assumeIsolated (similar to GetPlayerInfoCommand) or ensuring synchronous execution if PlayerService methods need to be awaited.
| Task { @MainActor in | ||
| guard let playerService = PlayerService.shared else { return } | ||
| playerService.toggleShuffle() | ||
| } | ||
| return nil |
There was a problem hiding this comment.
While toggleShuffle is not async, creating an unstructured Task when calling it is unnecessary and creates potential timing issues. Since PlayerService is marked @mainactor and the task runs on MainActor, you can use MainActor.assumeIsolated to call this method synchronously, similar to how GetPlayerInfoCommand handles its logic. This ensures the command completes before returning to AppleScript.
| import AppKit | ||
| import Foundation | ||
|
|
||
| // MARK: - AppleScript Commands | ||
|
|
||
| /// Play command: start or resume playback. | ||
| @objc(KasetPlayCommand) | ||
| final class PlayCommand: NSScriptCommand { | ||
| override func performDefaultImplementation() -> Any? { | ||
| Task { @MainActor in | ||
| guard let playerService = PlayerService.shared else { return } | ||
| await playerService.resume() | ||
| } | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| /// Pause command: pause playback. | ||
| @objc(KasetPauseCommand) | ||
| final class PauseCommand: NSScriptCommand { | ||
| override func performDefaultImplementation() -> Any? { | ||
| Task { @MainActor in | ||
| guard let playerService = PlayerService.shared else { return } | ||
| await playerService.pause() | ||
| } | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| /// PlayPause command: toggle play/pause state. | ||
| @objc(KasetPlayPauseCommand) | ||
| final class PlayPauseCommand: NSScriptCommand { | ||
| override func performDefaultImplementation() -> Any? { | ||
| Task { @MainActor in | ||
| guard let playerService = PlayerService.shared else { return } | ||
| await playerService.playPause() | ||
| } | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| /// NextTrack command: skip to the next track. | ||
| @objc(KasetNextTrackCommand) | ||
| final class NextTrackCommand: NSScriptCommand { | ||
| override func performDefaultImplementation() -> Any? { | ||
| Task { @MainActor in | ||
| guard let playerService = PlayerService.shared else { return } | ||
| await playerService.next() | ||
| } | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| /// PreviousTrack command: go to the previous track. | ||
| @objc(KasetPreviousTrackCommand) | ||
| final class PreviousTrackCommand: NSScriptCommand { | ||
| override func performDefaultImplementation() -> Any? { | ||
| Task { @MainActor in | ||
| guard let playerService = PlayerService.shared else { return } | ||
| await playerService.previous() | ||
| } | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| /// SetVolume command: set the playback volume (0-100). | ||
| @objc(KasetSetVolumeCommand) | ||
| final class SetVolumeCommand: NSScriptCommand { | ||
| override func performDefaultImplementation() -> Any? { | ||
| guard let volumeValue = self.directParameter as? Int else { | ||
| scriptErrorNumber = errAECoercionFail | ||
| return nil | ||
| } | ||
|
|
||
| let normalizedVolume = Double(max(0, min(100, volumeValue))) / 100.0 | ||
|
|
||
| Task { @MainActor in | ||
| guard let playerService = PlayerService.shared else { return } | ||
| await playerService.setVolume(normalizedVolume) | ||
| } | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| /// ToggleShuffle command: toggle shuffle mode. | ||
| @objc(KasetToggleShuffleCommand) | ||
| final class ToggleShuffleCommand: NSScriptCommand { | ||
| override func performDefaultImplementation() -> Any? { | ||
| Task { @MainActor in | ||
| guard let playerService = PlayerService.shared else { return } | ||
| playerService.toggleShuffle() | ||
| } | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| /// CycleRepeat command: cycle through repeat modes (off, all, one). | ||
| @objc(KasetCycleRepeatCommand) | ||
| final class CycleRepeatCommand: NSScriptCommand { | ||
| override func performDefaultImplementation() -> Any? { | ||
| Task { @MainActor in | ||
| guard let playerService = PlayerService.shared else { return } | ||
| playerService.cycleRepeatMode() | ||
| } | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| /// ToggleMute command: toggle mute state. | ||
| @objc(KasetToggleMuteCommand) | ||
| final class ToggleMuteCommand: NSScriptCommand { | ||
| override func performDefaultImplementation() -> Any? { | ||
| Task { @MainActor in | ||
| guard let playerService = PlayerService.shared else { return } | ||
| await playerService.toggleMute() | ||
| } | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| /// GetPlayerInfo command: returns current player state as JSON. | ||
| @objc(KasetGetPlayerInfoCommand) | ||
| final class GetPlayerInfoCommand: NSScriptCommand { | ||
| override func performDefaultImplementation() -> Any? { | ||
| // AppleScript runs on main thread, so we can assume MainActor isolation | ||
| let result: String = MainActor.assumeIsolated { | ||
| guard let playerService = PlayerService.shared else { | ||
| return "{\"error\": \"Player not available\"}" | ||
| } | ||
|
|
||
| let track = playerService.currentTrack | ||
| let repeatMode: String = switch playerService.repeatMode { | ||
| case .off: "off" | ||
| case .all: "all" | ||
| case .one: "one" | ||
| } | ||
|
|
||
| let likeStatus: String = switch playerService.currentTrackLikeStatus { | ||
| case .like: "liked" | ||
| case .dislike: "disliked" | ||
| case .indifferent: "none" | ||
| } | ||
|
|
||
| var info: [String: Any] = [ | ||
| "isPlaying": playerService.isPlaying, | ||
| "isPaused": playerService.state == .paused, | ||
| "position": playerService.progress, | ||
| "duration": playerService.duration, | ||
| "volume": Int(playerService.volume * 100), | ||
| "shuffling": playerService.shuffleEnabled, | ||
| "repeating": repeatMode, | ||
| "muted": playerService.isMuted, | ||
| "likeStatus": likeStatus, | ||
| ] | ||
|
|
||
| if let track { | ||
| info["currentTrack"] = [ | ||
| "name": track.title, | ||
| "artist": track.artistsDisplay, | ||
| "album": track.album?.title ?? "", | ||
| "duration": track.duration ?? 0, | ||
| "videoId": track.videoId, | ||
| "artworkURL": track.thumbnailURL?.absoluteString ?? "", | ||
| ] | ||
| } | ||
|
|
||
| if let data = try? JSONSerialization.data(withJSONObject: info, options: [.sortedKeys]), | ||
| let json = String(data: data, encoding: .utf8) | ||
| { | ||
| return json | ||
| } | ||
|
|
||
| return "{}" | ||
| } | ||
| return result | ||
| } | ||
| } | ||
|
|
||
| /// LikeTrack command: like/unlike the current track. | ||
| @objc(KasetLikeTrackCommand) | ||
| final class LikeTrackCommand: NSScriptCommand { | ||
| override func performDefaultImplementation() -> Any? { | ||
| Task { @MainActor in | ||
| guard let playerService = PlayerService.shared else { return } | ||
| playerService.likeCurrentTrack() | ||
| } | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| /// DislikeTrack command: dislike/undislike the current track. | ||
| @objc(KasetDislikeTrackCommand) | ||
| final class DislikeTrackCommand: NSScriptCommand { | ||
| override func performDefaultImplementation() -> Any? { | ||
| Task { @MainActor in | ||
| guard let playerService = PlayerService.shared else { return } | ||
| playerService.dislikeCurrentTrack() | ||
| } | ||
| return nil | ||
| } | ||
| } |
There was a problem hiding this comment.
The new ScriptCommands functionality lacks test coverage. Given that the repository has comprehensive test coverage for similar features (e.g., PlayerServiceTests.swift, URLHandlerTests.swift), tests should be added to verify that AppleScript commands correctly invoke PlayerService methods and handle edge cases like null PlayerService.shared.
Add AppleScript scripting support to enable external control of Kaset, primarily for Raycast extension integration. Commands added: - play, pause, playpause - playback control - next track, previous track - track navigation - set volume - volume control (0-100) - toggle shuffle, cycle repeat, toggle mute - playback modes - get player info - returns JSON with current state - like track, dislike track - rating controls Implementation: - App/Kaset.sdef: AppleScript command definitions - Core/Services/Scripting/ScriptCommands.swift: command handlers - PlayerService.shared: static accessor for script commands Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add MARK comments before each class - Remove redundant type annotations Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
15bca56 to
1deeeb9
Compare
- Add proper error reporting with scriptErrorNumber/scriptErrorString - Add DiagnosticsLogger.scripting for command logging - Use MainActor.assumeIsolated for synchronous methods - Document safety invariant for PlayerService.shared - Add comprehensive unit tests (18 tests) - Add AppleScript test scripts in Scripts/ - Update README with AppleScript documentation - Update architecture docs with ScriptCommands section Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Create docs/applescript.md with full documentation - Add JSON response example and error handling - Add Raycast, Alfred, and Shortcuts integration examples - Link from README features list to keep it concise Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Summary
Commands added
playpauseplaypausenext trackprevious trackset volume Ntoggle shufflecycle repeattoggle muteget player infolike trackdislike trackExample usage