Skip to content

feat: add AppleScript support for external control#82

Merged
sozercan merged 4 commits intosozercan:mainfrom
Endiruslan:feat/applescript-support
Jan 24, 2026
Merged

feat: add AppleScript support for external control#82
sozercan merged 4 commits intosozercan:mainfrom
Endiruslan:feat/applescript-support

Conversation

@Endiruslan
Copy link
Contributor

Summary

  • Add AppleScript scripting support to enable external control of Kaset
  • Enables integration with Raycast, Alfred, and other automation tools

Commands added

Command Description
play Start or resume playback
pause Pause playback
playpause Toggle play/pause
next track Skip to next track
previous track Go to previous track
set volume N Set volume (0-100)
toggle shuffle Toggle shuffle mode
cycle repeat Cycle repeat modes (off → all → one)
toggle mute Toggle mute
get player info Get JSON with current state
like track Like/unlike current track
dislike track Dislike/undislike current track

Example usage

tell application "Kaset"
    playpause
    set volume 50
    get player info
end tell

Test plan

- Build succeeds
- All commands tested via osascript
- get player info returns valid JSON

Copilot AI review requested due to automatic review settings January 22, 2026 14:55
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 22 to 25
Task { @MainActor in
guard let playerService = PlayerService.shared else { return }
await playerService.pause()
}
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 46 to 49
Task { @MainActor in
guard let playerService = PlayerService.shared else { return }
await playerService.next()
}
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 101 to 104
Task { @MainActor in
guard let playerService = PlayerService.shared else { return }
playerService.cycleRepeatMode()
}
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
Task { @MainActor in
guard let playerService = PlayerService.shared else { return }
playerService.cycleRepeatMode()
}
guard let playerService = PlayerService.shared else {
return nil
}
MainActor.assumeIsolated {
playerService.cycleRepeatMode()
}

Copilot uses AI. Check for mistakes.
Comment on lines 195 to 199
Task { @MainActor in
guard let playerService = PlayerService.shared else { return }
playerService.dislikeCurrentTrack()
}
return nil
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
<key>SUAllowsAutomaticUpdates</key>
<true/>

<!-- AppleScript Support -->
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
<!-- AppleScript Support -->

Copilot uses AI. Check for mistakes.
Comment on lines 10 to 13
Task { @MainActor in
guard let playerService = PlayerService.shared else { return }
await playerService.resume()
}
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 77 to 81
Task { @MainActor in
guard let playerService = PlayerService.shared else { return }
await playerService.setVolume(normalizedVolume)
}
return nil
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 113 to 116
Task { @MainActor in
guard let playerService = PlayerService.shared else { return }
await playerService.toggleMute()
}
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 89 to 93
Task { @MainActor in
guard let playerService = PlayerService.shared else { return }
playerService.toggleShuffle()
}
return nil
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 1 to 201
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
}
}
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Endiruslan and others added 2 commits January 23, 2026 10:44
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>
@Endiruslan Endiruslan force-pushed the feat/applescript-support branch from 15bca56 to 1deeeb9 Compare January 23, 2026 08:44
- 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>
Copy link
Owner

@sozercan sozercan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm, thanks!

- 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>
@sozercan sozercan merged commit 4d6d77c into sozercan:main Jan 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants