Skip to content

Commit 4d6d77c

Browse files
Endiruslanclaudesozercan
authored
feat: add AppleScript support for external control (#82)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Sertac Ozercan <sozercan@gmail.com>
1 parent 2139d7b commit 4d6d77c

File tree

13 files changed

+1202
-4
lines changed

13 files changed

+1202
-4
lines changed

App/Info.plist

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,11 @@
3232

3333
<key>SUAllowsAutomaticUpdates</key>
3434
<true/>
35+
36+
<!-- AppleScript Support -->
37+
<key>NSAppleScriptEnabled</key>
38+
<true/>
39+
<key>OSAScriptingDefinition</key>
40+
<string>Kaset.sdef</string>
3541
</dict>
3642
</plist>

App/Kaset.sdef

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE dictionary SYSTEM "file://localhost/System/Library/DTDs/sdef.dtd">
3+
<dictionary title="Kaset Terminology">
4+
5+
<!-- Standard Suite -->
6+
<suite name="Standard Suite" code="????" description="Common classes and commands for all applications.">
7+
8+
<command name="quit" code="aevtquit" description="Quit the application.">
9+
<cocoa class="NSQuitCommand"/>
10+
</command>
11+
12+
</suite>
13+
14+
<!-- Kaset Suite -->
15+
<suite name="Kaset Suite" code="Kast" description="Commands and classes for controlling Kaset.">
16+
17+
<!-- Playback Commands -->
18+
<command name="play" code="Kastplay" description="Start or resume playback.">
19+
<cocoa class="KasetPlayCommand"/>
20+
</command>
21+
22+
<command name="pause" code="Kastpaus" description="Pause playback.">
23+
<cocoa class="KasetPauseCommand"/>
24+
</command>
25+
26+
<command name="playpause" code="Kasttogg" description="Toggle play/pause state.">
27+
<cocoa class="KasetPlayPauseCommand"/>
28+
</command>
29+
30+
<command name="next track" code="Kastnext" description="Skip to the next track.">
31+
<cocoa class="KasetNextTrackCommand"/>
32+
</command>
33+
34+
<command name="previous track" code="Kastprev" description="Go to the previous track.">
35+
<cocoa class="KasetPreviousTrackCommand"/>
36+
</command>
37+
38+
<command name="set volume" code="Kastvolu" description="Set the playback volume.">
39+
<cocoa class="KasetSetVolumeCommand"/>
40+
<direct-parameter type="integer" description="Volume level (0-100)."/>
41+
</command>
42+
43+
<command name="toggle shuffle" code="Kastshuf" description="Toggle shuffle mode.">
44+
<cocoa class="KasetToggleShuffleCommand"/>
45+
</command>
46+
47+
<command name="cycle repeat" code="Kastrepe" description="Cycle through repeat modes (off, all, one).">
48+
<cocoa class="KasetCycleRepeatCommand"/>
49+
</command>
50+
51+
<command name="toggle mute" code="Kastmute" description="Toggle mute state.">
52+
<cocoa class="KasetToggleMuteCommand"/>
53+
</command>
54+
55+
<command name="get player info" code="Kastinfo" description="Get current player state as JSON.">
56+
<cocoa class="KasetGetPlayerInfoCommand"/>
57+
<result type="text" description="JSON string with player state."/>
58+
</command>
59+
60+
<command name="like track" code="Kastlike" description="Like or unlike the current track.">
61+
<cocoa class="KasetLikeTrackCommand"/>
62+
</command>
63+
64+
<command name="dislike track" code="Kastdslk" description="Dislike or undislike the current track.">
65+
<cocoa class="KasetDislikeTrackCommand"/>
66+
</command>
67+
68+
</suite>
69+
70+
</dictionary>

App/KasetApp.swift

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ struct KasetApp: App {
8888
player.setYTMusicClient(client)
8989
SongLikeStatusManager.shared.setClient(client)
9090

91+
// Set shared instance for AppleScript access
92+
PlayerService.shared = player
93+
9194
// Create account service
9295
let account = AccountService(ytMusicClient: client, authService: auth)
9396

@@ -125,10 +128,12 @@ struct KasetApp: App {
125128
.environment(\.searchFocusTrigger, self.$searchFocusTrigger)
126129
.environment(\.navigationSelection, self.$navigationSelection)
127130
.environment(\.showCommandBar, self.$showCommandBar)
128-
.task {
129-
// Wire up PlayerService to AppDelegate for dock menu actions
131+
.onAppear {
132+
// Wire up PlayerService to AppDelegate for dock menu and AppleScript actions
133+
// This runs synchronously so AppleScript commands can access playerService immediately
130134
self.appDelegate.playerService = self.playerService
131-
135+
}
136+
.task {
132137
// Check if user is already logged in from previous session
133138
await self.authService.checkLoginStatus()
134139

Core/Services/Player/PlayerService.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ import os
88
@MainActor
99
@Observable
1010
final class PlayerService: NSObject, PlayerServiceProtocol {
11+
/// Shared instance for AppleScript access.
12+
///
13+
/// **Safety Invariant:** This property is set exactly once during app initialization
14+
/// in `KasetApp.init()` before any AppleScript commands can be received, and is never
15+
/// modified afterward. The property is `@MainActor`-isolated along with the entire class,
16+
/// ensuring thread-safe access from AppleScript commands (which run on the main thread).
17+
///
18+
/// AppleScript commands should handle the `nil` case gracefully by returning an error
19+
/// to the caller, as there's a brief window during app launch before initialization completes.
20+
static var shared: PlayerService?
1121
/// Current playback state.
1222
enum PlaybackState: Equatable, Sendable {
1323
case idle

0 commit comments

Comments
 (0)