windows: fix config, IPC, path, and Lua compatibility (#258)

* windows: fix config, IPC, path, and Lua compatibility

* spotify: share API structures and helper so tests compile on Windows

* windows: address PR comments and fix socket unavailable detection, tasklist matching, api_fs tests, and doc comment

* windows: address remaining PR reviews (table-driven tests, m3u resolve comments & tests, blockquote formatting, and site/index.html description)

* feat: implement cross-platform IPC server with Unix and Windows support

* feat: implement Unix socket IPC server and Windows process liveness check

* ipc: detect dead processes via os.ErrProcessDone

os.Process.Signal converts ESRCH to os.ErrProcessDone since Go 1.16, so
comparing against raw syscall.ESRCH never matched and a stale socket from
a crashed instance made NewServer fail instead of cleaning it up.

* windows: simplify fs allowlist normalization, exec env, and ipc error checks

- normalize write allow-dirs once in the memoized writeAllowDirs
- collapse duplicate test helpers and repeated getenv blocks
- drop redundant errors.As branch; name WSAECONNREFUSED
- revert single-entry table test to linear form
- gofmt: trailing newlines and indentation

---------

Co-authored-by: Bjarne Øverli <bjarne.oeverli@gmail.com>
This commit is contained in:
GVASTE
2026-06-04 14:52:32 -03:00
committed by GitHub
parent 44b7037e21
commit d6c50ed623
31 changed files with 758 additions and 219 deletions
+10 -2
View File
@@ -46,6 +46,10 @@ Download from [GitHub Releases](https://github.com/bjarneo/cliamp/releases/lates
> **Linux:** the pre-built binaries statically link FLAC, Vorbis, and Ogg, so no
> extra codec packages are required. You may still need an ALSA bridge for your
> sound server — see [Troubleshooting](#troubleshooting).
>
> **Windows:** download `cliamp-windows-amd64.exe` from Releases. If `HOME` is not
> set, cliamp stores its config under `%APPDATA%\cliamp`. The Spotify provider is
> currently unavailable on Windows builds.
**Optional runtime dependencies** (all platforms, all install methods):
@@ -54,6 +58,8 @@ Download from [GitHub Releases](https://github.com/bjarneo/cliamp/releases/lates
On macOS: `brew install ffmpeg yt-dlp`. On Linux, use your distribution's package manager.
On Windows, install `ffmpeg` and `yt-dlp` with your preferred package manager and keep both on `PATH`.
**Build from source**
```sh
@@ -76,13 +82,13 @@ Press `Ctrl+K` to see all keybindings.
cliamp setup
```
It walks you through each provider, validates the connection, and writes the right block to `~/.config/cliamp/config.toml`. See [docs/cli.md](docs/cli.md#setup-wizard) for details.
It walks you through each provider, validates the connection, and writes the right block to your config file (`~/.config/cliamp/config.toml`, or `%APPDATA%\cliamp\config.toml` on Windows when `HOME` is unset). See [docs/cli.md](docs/cli.md#setup-wizard) for details.
## Radio
Press `R` in the player to browse and search 30,000+ online radio stations from the [Radio Browser](https://www.radio-browser.info/) directory.
Add your own stations to `~/.config/cliamp/radios.toml`. See [docs/configuration.md](docs/configuration.md#custom-radio-stations).
Add your own stations to `~/.config/cliamp/radios.toml` (or `%APPDATA%\cliamp\radios.toml` on Windows when `HOME` is unset). See [docs/configuration.md](docs/configuration.md#custom-radio-stations).
Want to host your own radio? Check out [cliamp-server](https://github.com/bjarneo/cliamp-server).
@@ -113,6 +119,8 @@ sudo pacman -S alsa-lib
**macOS:** No extra dependencies — CoreAudio is used.
**Windows:** No extra SDKs required for the core player. `ffmpeg.exe` and `yt-dlp.exe` remain optional runtime dependencies for the same formats/providers as on other platforms. Spotify is not available on Windows builds.
**Clone and build:**
```sh
+11
View File
@@ -8,6 +8,17 @@ cliamp setup
It validates your credentials live and writes the right TOML block without touching the rest of your config. See [cli.md](cli.md#setup-wizard) for details.
## Config directory
cliamp resolves its config directory in this order:
- `CLIAMP_CONFIG_DIR`
- `XDG_CONFIG_HOME/cliamp`
- `HOME/.config/cliamp`
- on Windows, `%APPDATA%\cliamp` when `HOME` is not set
The examples below use `~/.config/cliamp` for brevity. On Windows without `HOME`, replace that path with `%APPDATA%\cliamp`.
For everything else, copy the example config and edit by hand:
```sh
+1 -1
View File
@@ -303,7 +303,7 @@ cliamp.fs.mkdir(path) -- create directory (recursive)
cliamp.fs.listdir(path) --> {names}, err
```
Writes are restricted to `/tmp/`, `~/.config/cliamp/`, `~/.local/share/cliamp/`, and `~/Music/cliamp/`. Reads are allowed from anywhere.
Writes are restricted to the system temp directory (`/tmp/` on Unix), `~/.config/cliamp/`, `~/.local/share/cliamp/`, and `~/Music/cliamp/`. Reads are allowed from anywhere. On Windows, if `HOME` is unset, the config directory portion resolves to `%APPDATA%\cliamp`.
### cliamp.json
+3 -3
View File
@@ -2,7 +2,7 @@
Control a running cliamp instance from another terminal, a shell script, or an AI coding assistant.
When cliamp starts, it listens on a Unix domain socket at `~/.config/cliamp/cliamp.sock`. CLI subcommands connect to this socket to send playback commands and receive status.
When cliamp starts, it listens on a local IPC socket at `~/.config/cliamp/cliamp.sock` (or `%APPDATA%\cliamp\cliamp.sock` on Windows when `HOME` is unset). CLI subcommands connect to this socket to send playback commands and receive status. On Windows 10/11, this uses the same local socket transport via Go's AF_UNIX support.
## Playback Commands
@@ -96,7 +96,7 @@ Response:
## Protocol
The IPC protocol is newline-delimited JSON over a Unix domain socket. Each request is a single JSON object followed by a newline. The server responds with a single JSON object followed by a newline.
The IPC protocol is newline-delimited JSON over a local stream socket. Each request is a single JSON object followed by a newline. The server responds with a single JSON object followed by a newline.
Request format:
@@ -118,7 +118,7 @@ Response format:
## Socket Details
- **Path**: `~/.config/cliamp/cliamp.sock` (created on TUI start, removed on shutdown)
- **Path**: `~/.config/cliamp/cliamp.sock` (or `%APPDATA%\cliamp\cliamp.sock` on Windows when `HOME` is unset; created on TUI start, removed on shutdown)
- **Permissions**: `0600` (owner only)
- **Stale detection**: A PID file (`cliamp.sock.pid`) tracks the owning process. If cliamp crashes, the next instance detects the stale socket and cleans it up.
+2
View File
@@ -2,6 +2,8 @@
Cliamp can stream your [Spotify](https://www.spotify.com/) library directly through its audio pipeline. EQ, visualizer, and all effects apply. Requires a [Spotify Premium](https://www.spotify.com/premium/) account.
> **Windows:** Spotify is currently unavailable on Windows builds because the `go-librespot` playback backend used by cliamp does not compile there yet.
>
> **Quick start:** run `cliamp setup`, pick Spotify, and follow the prompts. The recommended path is to register your own Spotify Developer app and paste its `client_id` — it gives you a private rate-limit quota and works for playback, library, and playlists. There's also a built-in shared `client_id` available for users who specifically need Spotify search.
## Setup
-112
View File
@@ -37,42 +37,6 @@ var (
)
// maxResponseBody limits JSON API responses to 10 MB.
const maxResponseBody = 10 << 20
// Pagination limits for the Spotify Web API.
const (
spotifyPlaylistPageSize = 50
// spotifyTrackPageSize is capped at 50 because /v1/playlists/{id}/items
// silently truncates larger limits; requesting more would cause the loop
// to skip items when offset advances by the requested limit.
spotifyTrackPageSize = 50
)
// spotifyPlaylistItem is the raw playlist object returned by /v1/me/playlists.
type spotifyPlaylistItem struct {
ID string `json:"id"`
Name string `json:"name"`
SnapshotID string `json:"snapshot_id"`
Collaborative bool `json:"collaborative"`
Owner struct {
ID string `json:"id"`
} `json:"owner"`
Items *struct {
Total int `json:"total"`
} `json:"items"`
}
// playlistAccessible reports whether the playlist should be shown to the user.
// Playlists saved from other users (not owned, not collaborative) are excluded
// because the Spotify API returns 403 when listing their tracks.
// When userID is empty (fetch failed), all playlists are included as a fallback.
func playlistAccessible(item spotifyPlaylistItem, userID string) bool {
if userID == "" {
return true
}
return item.Owner.ID == userID || item.Collaborative
}
// SpotifyProvider implements playlist.Provider using the Spotify Web API
// for playlist/track metadata and go-librespot for audio streaming.
// playlistCache holds a snapshot_id and the fetched tracks for a playlist,
@@ -358,82 +322,6 @@ func (p *SpotifyProvider) Playlists() ([]playlist.PlaylistInfo, error) {
return slices.Clone(all), nil
}
type spotifyArtist struct {
Name string `json:"name"`
}
// spotifyItem is a track or podcast episode object from the Spotify Web API.
// Playlists can hold both; episodes carry a show instead of artists/album.
type spotifyItem struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // "track" or "episode"
URI string `json:"uri"` // canonical spotify:track:... / spotify:episode:...
Artists []spotifyArtist `json:"artists"`
Album struct {
Name string `json:"name"`
ReleaseDate string `json:"release_date"`
} `json:"album"`
Show struct {
Name string `json:"name"`
} `json:"show"`
ReleaseDate string `json:"release_date"` // episodes carry this at top level
DurationMs int `json:"duration_ms"`
TrackNumber int `json:"track_number"`
IsPlayable *bool `json:"is_playable"`
Restrictions struct {
Reason string `json:"reason"`
} `json:"restrictions"`
}
// trackFromItem converts a Spotify playlist/library item into a playlist.Track,
// handling both music tracks and podcast episodes. It uses the canonical uri
// the API returns (spotify:track:... or spotify:episode:...) as the path, so
// the player routes episodes to go-librespot's episode metadata path; building
// "spotify:track:<id>" for an episode makes go-librespot request track metadata
// for an episode id, which 404s. Episodes carry no artists/album, so the show
// name fills those slots for display.
func trackFromItem(t *spotifyItem) playlist.Track {
artists := make([]string, len(t.Artists))
for i, a := range t.Artists {
artists[i] = a.Name
}
artist := strings.Join(artists, ", ")
album := t.Album.Name
if t.Type == "episode" {
artist = t.Show.Name
album = t.Show.Name
}
releaseDate := t.Album.ReleaseDate
if releaseDate == "" {
releaseDate = t.ReleaseDate
}
var year int
if len(releaseDate) >= 4 {
if y, err := strconv.Atoi(releaseDate[:4]); err == nil {
year = y
}
}
path := t.URI
if path == "" {
path = fmt.Sprintf("spotify:track:%s", t.ID) // fallback if uri is absent
}
return playlist.Track{
Path: path,
Title: t.Name,
Artist: artist,
Album: album,
Year: year,
Stream: false, // must be false: true causes togglePlayPause to stop+restart instead of pause/resume
DurationSecs: t.DurationMs / 1000,
TrackNumber: t.TrackNumber,
Unplayable: (t.IsPlayable != nil && !*t.IsPlayable) || t.Restrictions.Reason != "",
}
}
// Tracks returns all tracks for the given Spotify playlist ID.
// Track.Path is set to the canonical spotify: URI for the player to resolve.
// Results are cached by snapshot_id; unchanged playlists skip the API call.
+122
View File
@@ -0,0 +1,122 @@
package spotify
import (
"fmt"
"strconv"
"strings"
"cliamp/playlist"
)
// maxResponseBody limits JSON API responses to 10 MB.
const maxResponseBody = 10 << 20
// Pagination limits for the Spotify Web API.
const (
spotifyPlaylistPageSize = 50
// spotifyTrackPageSize is capped at 50 because /v1/playlists/{id}/items
// silently truncates larger limits; requesting more would cause the loop
// to skip items when offset advances by the requested limit.
spotifyTrackPageSize = 50
)
// spotifyPlaylistItem is the raw playlist object returned by /v1/me/playlists.
type spotifyPlaylistItem struct {
ID string `json:"id"`
Name string `json:"name"`
SnapshotID string `json:"snapshot_id"`
Collaborative bool `json:"collaborative"`
Owner struct {
ID string `json:"id"`
} `json:"owner"`
Items *struct {
Total int `json:"total"`
} `json:"items"`
}
// playlistAccessible reports whether the playlist should be shown to the user.
// Playlists saved from other users (not owned, not collaborative) are excluded
// because the Spotify API returns 403 when listing their tracks.
// When userID is empty (fetch failed), all playlists are included as a fallback.
func playlistAccessible(item spotifyPlaylistItem, userID string) bool {
if userID == "" {
return true
}
return item.Owner.ID == userID || item.Collaborative
}
type spotifyArtist struct {
Name string `json:"name"`
}
// spotifyItem is a track or podcast episode object from the Spotify Web API.
// Playlists can hold both; episodes carry a show instead of artists/album.
type spotifyItem struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // "track" or "episode"
URI string `json:"uri"` // canonical spotify:track:... / spotify:episode:...
Artists []spotifyArtist `json:"artists"`
Album struct {
Name string `json:"name"`
ReleaseDate string `json:"release_date"`
} `json:"album"`
Show struct {
Name string `json:"name"`
} `json:"show"`
ReleaseDate string `json:"release_date"` // episodes carry this at top level
DurationMs int `json:"duration_ms"`
TrackNumber int `json:"track_number"`
IsPlayable *bool `json:"is_playable"`
Restrictions struct {
Reason string `json:"reason"`
} `json:"restrictions"`
}
// trackFromItem converts a Spotify playlist/library item into a playlist.Track,
// handling both music tracks and podcast episodes. It uses the canonical uri
// the API returns (spotify:track:... or spotify:episode:...) as the path, so
// the player routes episodes to go-librespot's episode metadata path; building
// "spotify:track:<id>" for an episode makes go-librespot request track metadata
// for an episode id, which 404s. Episodes carry no artists/album, so the show
// name fills those slots for display.
func trackFromItem(t *spotifyItem) playlist.Track {
artists := make([]string, len(t.Artists))
for i, a := range t.Artists {
artists[i] = a.Name
}
artist := strings.Join(artists, ", ")
album := t.Album.Name
if t.Type == "episode" {
artist = t.Show.Name
album = t.Show.Name
}
releaseDate := t.Album.ReleaseDate
if releaseDate == "" {
releaseDate = t.ReleaseDate
}
var year int
if len(releaseDate) >= 4 {
if y, err := strconv.Atoi(releaseDate[:4]); err == nil {
year = y
}
}
path := t.URI
if path == "" {
path = fmt.Sprintf("spotify:track:%s", t.ID) // fallback if uri is absent
}
return playlist.Track{
Path: path,
Title: t.Name,
Artist: artist,
Album: album,
Year: year,
Stream: false, // must be false: true causes togglePlayPause to stop+restart instead of pause/resume
DurationSecs: t.DurationMs / 1000,
TrackNumber: t.TrackNumber,
Unplayable: (t.IsPlayable != nil && !*t.IsPlayable) || t.Restrictions.Reason != "",
}
}
+24 -2
View File
@@ -3,10 +3,32 @@ package appdir
import (
"os"
"path/filepath"
"runtime"
)
// Dir returns the cliamp configuration directory (~/.config/cliamp).
// Dir returns the cliamp configuration directory.
//
// Resolution order:
// - CLIAMP_CONFIG_DIR (explicit override)
// - XDG_CONFIG_HOME/cliamp
// - HOME/.config/cliamp
// - on Windows: APPDATA/cliamp
// - fallback: os.UserHomeDir()/.config/cliamp
func Dir() (string, error) {
if dir, ok := os.LookupEnv("CLIAMP_CONFIG_DIR"); ok && dir != "" {
return dir, nil
}
if xdg, ok := os.LookupEnv("XDG_CONFIG_HOME"); ok && xdg != "" {
return filepath.Join(xdg, "cliamp"), nil
}
if home, ok := os.LookupEnv("HOME"); ok && home != "" {
return filepath.Join(home, ".config", "cliamp"), nil
}
if runtime.GOOS == "windows" {
if appData, ok := os.LookupEnv("APPDATA"); ok && appData != "" {
return filepath.Join(appData, "cliamp"), nil
}
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
@@ -14,7 +36,7 @@ func Dir() (string, error) {
return filepath.Join(home, ".config", "cliamp"), nil
}
// PluginDir returns the cliamp plugin directory (~/.config/cliamp/plugins).
// PluginDir returns the cliamp plugin directory.
func PluginDir() (string, error) {
dir, err := Dir()
if err != nil {
+56 -8
View File
@@ -1,26 +1,69 @@
package appdir
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestDir(t *testing.T) {
dir, err := Dir()
if err != nil {
t.Fatalf("Dir() error: %v", err)
tests := []struct {
name string
env map[string]string
want func(tempDir string) string
windowsOnly bool
}{
{
name: "home config",
env: map[string]string{"CLIAMP_CONFIG_DIR": "", "XDG_CONFIG_HOME": "", "APPDATA": "", "HOME": "TEMPDIR"},
want: func(tmp string) string { return filepath.Join(tmp, ".config", "cliamp") },
},
{
name: "xdg config",
env: map[string]string{"CLIAMP_CONFIG_DIR": "", "HOME": "", "APPDATA": "", "XDG_CONFIG_HOME": "TEMPDIR"},
want: func(tmp string) string { return filepath.Join(tmp, "cliamp") },
},
{
name: "appdata on windows when home missing",
windowsOnly: true,
env: map[string]string{"CLIAMP_CONFIG_DIR": "", "XDG_CONFIG_HOME": "", "HOME": "", "APPDATA": "TEMPDIR"},
want: func(tmp string) string { return filepath.Join(tmp, "cliamp") },
},
}
home, _ := os.UserHomeDir()
want := filepath.Join(home, ".config", "cliamp")
if dir != want {
t.Fatalf("Dir() = %q, want %q", dir, want)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.windowsOnly && runtime.GOOS != "windows" {
t.Skip("Windows-specific fallback")
}
var tempDir string
for k, v := range tt.env {
if v == "TEMPDIR" {
tempDir = t.TempDir()
t.Setenv(k, tempDir)
} else {
t.Setenv(k, v)
}
}
got, err := Dir()
if err != nil {
t.Fatalf("Dir() error: %v", err)
}
want := tt.want(tempDir)
if got != want {
t.Fatalf("Dir() = %q, want %q", got, want)
}
})
}
}
func TestPluginDir(t *testing.T) {
t.Setenv("CLIAMP_CONFIG_DIR", "")
t.Setenv("XDG_CONFIG_HOME", "")
t.Setenv("APPDATA", "")
t.Setenv("HOME", t.TempDir())
dir, err := PluginDir()
if err != nil {
t.Fatalf("PluginDir() error: %v", err)
@@ -32,6 +75,11 @@ func TestPluginDir(t *testing.T) {
}
func TestPluginDirIsSubdirOfDir(t *testing.T) {
t.Setenv("CLIAMP_CONFIG_DIR", "")
t.Setenv("XDG_CONFIG_HOME", "")
t.Setenv("APPDATA", "")
t.Setenv("HOME", t.TempDir())
base, _ := Dir()
plugin, _ := PluginDir()
+2 -5
View File
@@ -3,12 +3,9 @@ package ipc
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"syscall"
"time"
"cliamp/internal/appdir"
@@ -33,9 +30,9 @@ func Send(sockPath string, req Request) (Response, error) {
// deadline. Plugin commands can legitimately run for minutes (downloads), so
// the generic 5s cap is too short for them.
func SendWithDeadline(sockPath string, req Request, deadline time.Duration) (Response, error) {
conn, err := net.DialTimeout("unix", sockPath, 3*time.Second)
conn, err := dialSocket(sockPath, 3*time.Second)
if err != nil {
if errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ECONNREFUSED) {
if isSocketUnavailable(err) {
return Response{}, fmt.Errorf("cliamp is not running (no socket at %s)", sockPath)
}
return Response{}, fmt.Errorf("connect: %w", err)
+1 -1
View File
@@ -9,5 +9,5 @@ import (
// package can send arbitrary bytes instead of going through Send (which
// wraps a Request as JSON).
func dialWithTimeout(sockPath string, d time.Duration) (net.Conn, error) {
return net.DialTimeout("unix", sockPath, d)
return dialSocket(sockPath, d)
}
+38
View File
@@ -0,0 +1,38 @@
package ipc
import (
"errors"
"net"
"os"
"strings"
"syscall"
"time"
)
func dialSocket(sockPath string, timeout time.Duration) (net.Conn, error) {
return net.DialTimeout("unix", sockPath, timeout)
}
func listenSocket(sockPath string) (net.Listener, error) {
return net.Listen("unix", sockPath)
}
// wsaeConnRefused is Windows' WSAECONNREFUSED, returned when dialing an
// AF_UNIX socket nobody is listening on.
const wsaeConnRefused = syscall.Errno(10061)
func isSocketUnavailable(err error) bool {
if err == nil {
return false
}
if errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, wsaeConnRefused) {
return true
}
// Last resort for platform errors that arrive untyped (Windows AF_UNIX
// messages vary by version).
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "refused") ||
strings.Contains(msg, "dead network") ||
strings.Contains(msg, "no such file") ||
strings.Contains(msg, "cannot find the file")
}
+66
View File
@@ -0,0 +1,66 @@
package ipc
import (
"errors"
"fmt"
"os"
"syscall"
"testing"
)
func TestIsSocketUnavailable(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{
name: "windows dead network AF_UNIX error",
err: errors.New("connect: A socket operation encountered a dead network"),
want: true,
},
{
name: "actively refused",
err: errors.New("connect: No connection could be made because the target machine actively refused it"),
want: true,
},
{
name: "unrelated network error",
err: errors.New("connect: some other error"),
want: false,
},
{
name: "nil error",
err: nil,
want: false,
},
{
name: "wrapped not-exist",
err: fmt.Errorf("dial: %w", os.ErrNotExist),
want: true,
},
{
name: "wrapped ECONNREFUSED",
err: fmt.Errorf("dial: %w", syscall.ECONNREFUSED),
want: true,
},
{
name: "WSAECONNREFUSED error",
err: syscall.Errno(10061),
want: true,
},
{
name: "wrapped WSAECONNREFUSED error",
err: fmt.Errorf("dial: %w", syscall.Errno(10061)),
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isSocketUnavailable(tt.err); got != tt.want {
t.Fatalf("isSocketUnavailable(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
+36
View File
@@ -0,0 +1,36 @@
//go:build !windows
package ipc
import (
"errors"
"fmt"
"os"
"syscall"
)
func processAlive(pid int) (bool, error) {
if pid <= 0 {
return false, nil
}
if pid == os.Getpid() {
return true, nil
}
proc, err := os.FindProcess(pid)
if err != nil {
return false, fmt.Errorf("probe process liveness: %w", err)
}
err = proc.Signal(syscall.Signal(0))
if err == nil {
return true, nil
}
// os.Process.Signal converts the kernel's ESRCH into os.ErrProcessDone.
if errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH) {
return false, nil
}
// EPERM means the process exists but belongs to another user.
if errors.Is(err, syscall.EPERM) {
return true, nil
}
return false, fmt.Errorf("probe process liveness: %w", err)
}
+44
View File
@@ -0,0 +1,44 @@
//go:build windows
package ipc
import (
"context"
"encoding/csv"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"time"
)
func processAlive(pid int) (bool, error) {
if pid <= 0 {
return false, nil
}
if pid == os.Getpid() {
return true, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "tasklist", "/FI", fmt.Sprintf("PID eq %d", pid), "/FO", "CSV", "/NH").Output()
if err != nil {
return false, fmt.Errorf("probe process liveness: %w", err)
}
r := csv.NewReader(strings.NewReader(string(out)))
r.FieldsPerRecord = -1
records, err := r.ReadAll()
if err != nil {
return false, fmt.Errorf("parse tasklist output: %w", err)
}
pidStr := strconv.Itoa(pid)
for _, record := range records {
if len(record) > 1 {
if strings.Trim(record[1], ` "`) == pidStr {
return true, nil
}
}
}
return false, nil
}
+34
View File
@@ -0,0 +1,34 @@
//go:build windows
package ipc
import (
"math"
"os"
"testing"
)
func TestProcessAlive(t *testing.T) {
tests := []struct {
name string
pid int
want bool
}{
{name: "negative pid", pid: -1, want: false},
{name: "zero pid", pid: 0, want: false},
{name: "current process", pid: os.Getpid(), want: true},
{name: "unlikely pid", pid: math.MaxInt32, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := processAlive(tt.pid)
if err != nil {
t.Fatalf("processAlive(%d) unexpected error: %v", tt.pid, err)
}
if got != tt.want {
t.Fatalf("processAlive(%d) = %v, want %v", tt.pid, got, tt.want)
}
})
}
}
+4 -10
View File
@@ -11,7 +11,6 @@ import (
"strconv"
"strings"
"sync"
"syscall"
"time"
"cliamp/applog"
@@ -85,7 +84,7 @@ func NewServer(sockPath string, disp Dispatcher) (*Server, error) {
return nil, fmt.Errorf("ipc: mkdir: %w", err)
}
ln, err := net.Listen("unix", sockPath)
ln, err := listenSocket(sockPath)
if err != nil {
return nil, fmt.Errorf("ipc: listen: %w", err)
}
@@ -382,16 +381,11 @@ func cleanStaleSocket(sockPath string) error {
return nil
}
proc, err := os.FindProcess(pid)
alive, err := processAlive(pid)
if err != nil {
// Can't find process — clean up.
os.Remove(pidPath)
os.Remove(sockPath)
return nil
return fmt.Errorf("checking process liveness for socket %s: %w", sockPath, err)
}
// Signal 0 checks if the process exists without actually sending a signal.
if err := proc.Signal(syscall.Signal(0)); err != nil {
if !alive {
// Process is dead — clean up stale files.
os.Remove(pidPath)
os.Remove(sockPath)
+2 -6
View File
@@ -4,12 +4,8 @@ import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"syscall"
"time"
)
@@ -21,9 +17,9 @@ func StreamBands(ctx context.Context, sockPath string, interval time.Duration, o
interval = 33 * time.Millisecond
}
conn, err := net.DialTimeout("unix", sockPath, 3*time.Second)
conn, err := dialSocket(sockPath, 3*time.Second)
if err != nil {
if errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ECONNREFUSED) {
if isSocketUnavailable(err) {
return fmt.Errorf("cliamp is not running (no socket at %s)", sockPath)
}
return fmt.Errorf("connect: %w", err)
+16 -9
View File
@@ -243,7 +243,7 @@ func registerExecAPI(L *lua.LState, cliamp *lua.LTable, em *execManager, p *Plug
}
// Empty env by default — plugins should not inherit secrets like
// AWS_*, SSH_*, etc. yt-dlp and ffmpeg both run fine with a minimal env.
cmd.Env = []string{"PATH=/usr/local/bin:/usr/bin:/bin", "HOME=" + homeEnv(), "LANG=C.UTF-8"}
cmd.Env = minimalExecEnv()
stdout, err := cmd.StdoutPipe()
if err != nil {
@@ -312,18 +312,21 @@ func registerExecAPI(L *lua.LState, cliamp *lua.LTable, em *execManager, p *Plug
go func() {
wg.Wait()
waitErr := cmd.Wait()
ctxErr := ctx.Err()
cancel()
em.remove(entry)
code := 0
if waitErr != nil {
var exitErr *exec.ExitError
if errors.As(waitErr, &exitErr) {
code = exitErr.ExitCode()
} else if ctx.Err() != nil {
if ctxErr != nil {
code = -1 // cancelled or timed out
} else {
code = -2 // other error
var exitErr *exec.ExitError
if errors.As(waitErr, &exitErr) {
code = exitErr.ExitCode()
} else {
code = -2 // other error
}
}
}
@@ -363,11 +366,15 @@ func registerExecAPI(L *lua.LState, cliamp *lua.LTable, em *execManager, p *Plug
L.SetField(cliamp, "exec", tbl)
}
// homeEnv returns the user's home directory for subprocess HOME, or "/" if
// unset. yt-dlp and ffmpeg both read HOME (~/.cache, ~/.config).
// homeEnv returns the user's home directory for subprocess HOME, preferring
// $HOME, then os.UserHomeDir(), falling back to os.TempDir() when unset.
// yt-dlp and ffmpeg both read HOME (~/.cache, ~/.config).
func homeEnv() string {
if home, ok := os.LookupEnv("HOME"); ok && home != "" {
return home
}
if h, err := os.UserHomeDir(); err == nil {
return h
}
return "/"
return os.TempDir()
}
+88 -25
View File
@@ -1,7 +1,10 @@
package luaplugin
import (
"fmt"
"os"
"runtime"
"strconv"
"strings"
"sync"
"testing"
@@ -11,7 +14,8 @@ import (
)
// newExecTestState builds a minimal plugin + manager wired up for exec tests.
// The binary allowlist is scoped to basic POSIX tools so tests don't need yt-dlp.
// The binary allowlist is scoped to a few harmless OS-native tools so tests
// don't need yt-dlp or ffmpeg.
func newExecTestState(t *testing.T, perms []string) (*lua.LState, *Plugin, *execManager, func()) {
t.Helper()
L := lua.NewState()
@@ -24,7 +28,7 @@ func newExecTestState(t *testing.T, perms []string) (*lua.LState, *Plugin, *exec
}
}
em := newExecManager([]string{"echo", "false", "sleep", "sh", "cat"})
em := newExecManager(execTestAllowedBinaries())
cliamp := L.NewTable()
registerExecAPI(L, cliamp, em, p, newPluginLogger(""))
L.SetGlobal("cliamp", cliamp)
@@ -32,6 +36,55 @@ func newExecTestState(t *testing.T, perms []string) (*lua.LState, *Plugin, *exec
return L, p, em, func() { em.stopAll(); L.Close() }
}
func execTestAllowedBinaries() []string {
if runtime.GOOS == "windows" {
return []string{"cmd", "powershell"}
}
return []string{"echo", "false", "sleep", "sh", "cat"}
}
func execOutputCommand() (binary string, args []string, wantLine string) {
if runtime.GOOS == "windows" {
return "powershell", []string{"-NoProfile", "-Command", "Write-Output 'hello world'"}, "hello world"
}
return "echo", []string{"hello", "world"}, "hello world"
}
func execFailureCommand() (binary string, args []string) {
if runtime.GOOS == "windows" {
return "powershell", []string{"-NoProfile", "-Command", "exit 1"}
}
return "false", nil
}
func execSleepCommand(seconds int) (binary string, args []string) {
if runtime.GOOS == "windows" {
return "powershell", []string{"-NoProfile", "-Command", fmt.Sprintf("Start-Sleep -Seconds %d", seconds)}
}
return "sleep", []string{strconv.Itoa(seconds)}
}
func execDisallowedCWD() string {
if runtime.GOOS == "windows" {
if root := os.Getenv("WINDIR"); root != "" {
return root
}
return `C:\Windows`
}
return "/etc"
}
func luaStringList(items []string) string {
if len(items) == 0 {
return ""
}
parts := make([]string, 0, len(items))
for _, item := range items {
parts = append(parts, fmt.Sprintf("%q", item))
}
return strings.Join(parts, ", ")
}
// waitExec polls for a Lua global to be set. The plugin mutex must be held
// while touching LState — exec goroutines also take it before calling into
// Lua, so reading without the lock would race on LState itself.
@@ -53,20 +106,21 @@ func waitExec(t *testing.T, p *Plugin, L *lua.LState, name string, timeout time.
func TestExecRunsAllowedBinary(t *testing.T) {
L, p, _, cleanup := newExecTestState(t, []string{"exec"})
defer cleanup()
binary, args, wantLine := execOutputCommand()
// Drive callbacks into globals so the test can assert state, but acquire
// the plugin mutex first — the exec goroutines also take it before calling
// Lua, so without locking here we'd race on LState itself.
p.mu.Lock()
err := L.DoString(`
err := L.DoString(fmt.Sprintf(`
_G.lines = {}
_G.exit_code = nil
local h, err = cliamp.exec.run("echo", {"hello", "world"}, {
local h, err = cliamp.exec.run(%q, {%s}, {
on_stdout = function(line) table.insert(_G.lines, line) end,
on_exit = function(code) _G.exit_code = code end,
})
assert(h, tostring(err))
`)
`, binary, luaStringList(args)))
p.mu.Unlock()
if err != nil {
t.Fatal(err)
@@ -80,7 +134,14 @@ func TestExecRunsAllowedBinary(t *testing.T) {
t.Fatalf("exit_code = %v, want 0", code)
}
lines := L.GetGlobal("lines").(*lua.LTable)
if lines.Len() != 1 || lines.RawGetInt(1).String() != "hello world" {
matched := false
for i := 1; i <= lines.Len(); i++ {
if strings.TrimSpace(lines.RawGetInt(i).String()) == wantLine {
matched = true
break
}
}
if !matched {
t.Fatalf("unexpected stdout: %v", lines)
}
}
@@ -128,14 +189,15 @@ func TestExecRequiresPermission(t *testing.T) {
func TestExecPropagatesExitCode(t *testing.T) {
L, p, _, cleanup := newExecTestState(t, []string{"exec"})
defer cleanup()
binary, args := execFailureCommand()
p.mu.Lock()
err := L.DoString(`
err := L.DoString(fmt.Sprintf(`
_G.exit_code = nil
cliamp.exec.run("false", {}, {
cliamp.exec.run(%q, {%s}, {
on_exit = function(code) _G.exit_code = code end,
})
`)
`, binary, luaStringList(args)))
p.mu.Unlock()
if err != nil {
t.Fatal(err)
@@ -152,14 +214,15 @@ func TestExecPropagatesExitCode(t *testing.T) {
func TestExecCancel(t *testing.T) {
L, p, _, cleanup := newExecTestState(t, []string{"exec"})
defer cleanup()
binary, args := execSleepCommand(10)
p.mu.Lock()
err := L.DoString(`
err := L.DoString(fmt.Sprintf(`
_G.exit_code = nil
_G.handle = cliamp.exec.run("sleep", {"10"}, {
_G.handle = cliamp.exec.run(%q, {%s}, {
on_exit = function(code) _G.exit_code = code end,
})
`)
`, binary, luaStringList(args)))
p.mu.Unlock()
if err != nil {
t.Fatal(err)
@@ -184,20 +247,21 @@ func TestExecCancel(t *testing.T) {
func TestExecConcurrencyCap(t *testing.T) {
L, p, _, cleanup := newExecTestState(t, []string{"exec"})
defer cleanup()
binary, args := execSleepCommand(2)
p.mu.Lock()
err := L.DoString(`
err := L.DoString(fmt.Sprintf(`
_G.errs = {}
_G.handles = {}
for i = 1, 6 do
local h, err = cliamp.exec.run("sleep", {"2"}, {})
local h, err = cliamp.exec.run(%q, {%s}, {})
if h then
table.insert(_G.handles, h)
else
table.insert(_G.errs, err or "?")
end
end
`)
`, binary, luaStringList(args)))
p.mu.Unlock()
if err != nil {
t.Fatal(err)
@@ -228,6 +292,7 @@ func TestExecConcurrencyCap(t *testing.T) {
func TestExecStopPluginKillsChildren(t *testing.T) {
L, p, em, cleanup := newExecTestState(t, []string{"exec"})
defer cleanup()
binary, args := execSleepCommand(10)
var exits sync.WaitGroup
exits.Add(1)
@@ -237,11 +302,11 @@ func TestExecStopPluginKillsChildren(t *testing.T) {
exits.Done()
return 0
}))
err := L.DoString(`
cliamp.exec.run("sleep", {"10"}, {
err := L.DoString(fmt.Sprintf(`
cliamp.exec.run(%q, {%s}, {
on_exit = function() notify() end,
})
`)
`, binary, luaStringList(args)))
p.mu.Unlock()
if err != nil {
t.Fatal(err)
@@ -290,13 +355,15 @@ func TestResolveAllowedBinaries(t *testing.T) {
func TestExecCwdMustBeAllowed(t *testing.T) {
L, p, _, cleanup := newExecTestState(t, []string{"exec"})
defer cleanup()
binary, args, _ := execOutputCommand()
cwd := execDisallowedCWD()
p.mu.Lock()
err := L.DoString(`
local h, err = cliamp.exec.run("echo", {"hi"}, {cwd = "/etc"})
err := L.DoString(fmt.Sprintf(`
local h, err = cliamp.exec.run(%q, {%s}, {cwd = %q})
_G.handle = h
_G.err = err
`)
`, binary, luaStringList(args), cwd))
p.mu.Unlock()
if err != nil {
t.Fatal(err)
@@ -311,10 +378,6 @@ func TestExecCwdMustBeAllowed(t *testing.T) {
// Guard against a regression where a missing binary silently returns a handle.
func TestExecBinaryNotOnPath(t *testing.T) {
if _, err := os.Stat("/bin/echo"); err != nil {
t.Skip("/bin/echo missing, can't run exec tests on this host")
}
L := lua.NewState()
defer L.Close()
p := &Plugin{Name: "test", L: L, perms: map[string]bool{"exec": true}}
+17 -5
View File
@@ -4,6 +4,7 @@ import (
"io"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
@@ -19,8 +20,9 @@ var (
// writeAllowDirs returns the directories where plugins can write files, with
// symlinks resolved so the prefix check in isWriteAllowed cannot be bypassed
// by a symlinked allow dir (e.g. /tmp -> /private/tmp on macOS). The result is
// cached since these paths never change at runtime.
// by a symlinked allow dir (e.g. /tmp -> /private/tmp on macOS). Entries are
// normalized via normalizeWritePath so isWriteAllowed can compare directly.
// The result is cached since these paths never change at runtime.
func writeAllowDirs() []string {
allowDirsOnce.Do(func() {
raw := []string{"/tmp", os.TempDir()}
@@ -31,7 +33,6 @@ func writeAllowDirs() []string {
raw = append(raw, filepath.Join(home, ".local", "share", "cliamp"))
raw = append(raw, filepath.Join(home, "Music", "cliamp"))
}
sep := string(os.PathSeparator)
for _, d := range raw {
abs, err := filepath.Abs(d)
if err != nil {
@@ -40,7 +41,7 @@ func writeAllowDirs() []string {
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
abs = resolved
}
allowDirs = append(allowDirs, abs+sep)
allowDirs = append(allowDirs, normalizeWritePath(abs))
}
})
return allowDirs
@@ -80,14 +81,25 @@ func isWriteAllowed(path string) bool {
if !ok {
return false
}
abs = normalizeWritePath(abs)
for _, dir := range writeAllowDirs() {
if strings.HasPrefix(abs, dir) {
if abs == dir || strings.HasPrefix(abs, dir+string(os.PathSeparator)) {
return true
}
}
return false
}
// normalizeWritePath canonicalizes an absolute path for prefix comparison:
// cleaned and, on Windows, case-folded (Windows paths are case-insensitive).
func normalizeWritePath(path string) string {
path = filepath.Clean(path)
if runtime.GOOS == "windows" {
path = strings.ToLower(path)
}
return path
}
// registerFSAPI adds cliamp.fs.{write,append,read,remove,exists} to the cliamp table.
func registerFSAPI(L *lua.LState, cliamp *lua.LTable) {
tbl := L.NewTable()
+27 -14
View File
@@ -1,13 +1,29 @@
package luaplugin
import (
"fmt"
"os"
"path/filepath"
"runtime"
"testing"
lua "github.com/yuin/gopher-lua"
)
func fsAllowedPath(name string) string {
return filepath.Join(os.TempDir(), name)
}
func fsDisallowedPath() string {
if runtime.GOOS == "windows" {
if root := os.Getenv("WINDIR"); root != "" {
return filepath.Join(root, "System32", "drivers", "etc", "hosts")
}
return `C:\Windows\System32\drivers\etc\hosts`
}
return "/etc/passwd"
}
func TestFSWriteAndRead(t *testing.T) {
L := lua.NewState()
defer L.Close()
@@ -15,7 +31,7 @@ func TestFSWriteAndRead(t *testing.T) {
registerFSAPI(L, cliamp)
L.SetGlobal("cliamp", cliamp)
tmp := filepath.Join("/tmp", "cliamp-test-"+t.Name())
tmp := fsAllowedPath("cliamp-test-" + t.Name())
defer os.Remove(tmp)
L.SetGlobal("path", lua.LString(tmp))
@@ -44,7 +60,7 @@ func TestFSAppend(t *testing.T) {
registerFSAPI(L, cliamp)
L.SetGlobal("cliamp", cliamp)
tmp := filepath.Join("/tmp", "cliamp-test-append-"+t.Name())
tmp := fsAllowedPath("cliamp-test-append-" + t.Name())
defer os.Remove(tmp)
L.SetGlobal("path", lua.LString(tmp))
@@ -69,12 +85,12 @@ func TestFSExists(t *testing.T) {
registerFSAPI(L, cliamp)
L.SetGlobal("cliamp", cliamp)
tmp := filepath.Join("/tmp", "cliamp-test-exists-"+t.Name())
tmp := fsAllowedPath("cliamp-test-exists-" + t.Name())
os.WriteFile(tmp, []byte("x"), 0o644)
defer os.Remove(tmp)
L.SetGlobal("path", lua.LString(tmp))
L.SetGlobal("fake", lua.LString("/tmp/cliamp-definitely-not-here"))
L.SetGlobal("fake", lua.LString(fsAllowedPath("cliamp-definitely-not-here")))
err := L.DoString(`
_G.exists = cliamp.fs.exists(path)
_G.not_exists = cliamp.fs.exists(fake)
@@ -98,7 +114,7 @@ func TestFSRemove(t *testing.T) {
registerFSAPI(L, cliamp)
L.SetGlobal("cliamp", cliamp)
tmp := filepath.Join("/tmp", "cliamp-test-remove-"+t.Name())
tmp := fsAllowedPath("cliamp-test-remove-" + t.Name())
os.WriteFile(tmp, []byte("x"), 0o644)
L.SetGlobal("path", lua.LString(tmp))
@@ -123,9 +139,8 @@ func TestIsWriteAllowed(t *testing.T) {
path string
want bool
}{
{"/tmp/test.txt", true},
{"/etc/passwd", false},
{"/home/user/.ssh/id_rsa", false},
{fsAllowedPath("test.txt"), true},
{fsDisallowedPath(), false},
}
for _, tt := range tests {
@@ -142,7 +157,7 @@ func TestFSMkdirAndListdir(t *testing.T) {
registerFSAPI(L, cliamp)
L.SetGlobal("cliamp", cliamp)
base := filepath.Join("/tmp", "cliamp-test-mkdir-"+t.Name())
base := fsAllowedPath("cliamp-test-mkdir-" + t.Name())
defer os.RemoveAll(base)
L.SetGlobal("base", lua.LString(base))
@@ -176,17 +191,15 @@ func TestFSMkdirRejectsOutsideAllowlist(t *testing.T) {
registerFSAPI(L, cliamp)
L.SetGlobal("cliamp", cliamp)
err := L.DoString(`cliamp.fs.mkdir("/etc/cliamp-evil")`)
err := L.DoString(fmt.Sprintf("cliamp.fs.mkdir(%q)", fsDisallowedPath()))
if err == nil {
t.Fatal("expected error for path outside allowlist")
}
}
func TestMusicDirIsAllowed(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("no home dir")
}
home := t.TempDir()
t.Setenv("HOME", home)
path := filepath.Join(home, "Music", "cliamp", "album", "01.mp3")
if !isWriteAllowed(path) {
t.Errorf("~/Music/cliamp/... should be writable")
+17
View File
@@ -0,0 +1,17 @@
//go:build !windows
package luaplugin
import "os"
func minimalExecEnv() []string {
path := os.Getenv("PATH")
if path == "" {
path = "/usr/local/bin:/usr/bin:/bin"
}
return []string{
"PATH=" + path,
"HOME=" + homeEnv(),
"LANG=C.UTF-8",
}
}
+22
View File
@@ -0,0 +1,22 @@
//go:build windows
package luaplugin
import "os"
func minimalExecEnv() []string {
home := homeEnv()
env := []string{
"PATH=" + os.Getenv("PATH"),
"HOME=" + home,
"USERPROFILE=" + home,
}
// Pass through the Windows variables subprocesses commonly need; skip
// any that are unset.
for _, key := range []string{"APPDATA", "LOCALAPPDATA", "ComSpec", "PATHEXT", "SystemRoot", "WINDIR", "TEMP", "TMP"} {
if v := os.Getenv(key); v != "" {
env = append(env, key+"="+v)
}
}
return env
}
+1
View File
@@ -411,6 +411,7 @@ func TestControlClampsBounds(t *testing.T) {
func TestControlWithoutPermissionIsNoop(t *testing.T) {
m := newTestManager()
m.logger = newPluginLogger(filepath.Join(t.TempDir(), "test.log"))
t.Cleanup(m.Close)
called := false
m.SetControlProvider(ControlProvider{
SetVolume: func(db float64) { called = true },
+5 -1
View File
@@ -98,7 +98,11 @@ func run(overrides config.Overrides, positional []string, daemon bool) error {
if cfg.Spotify.IsSet() {
clientID := cfg.Spotify.ResolveClientID(spotify.DefaultClientID)
spotifyProv = spotify.New(nil, clientID, cfg.Spotify.Bitrate)
providers = append(providers, model.ProviderEntry{Key: "spotify", Name: "Spotify", Provider: spotifyProv})
if spotifyProv != nil {
providers = append(providers, model.ProviderEntry{Key: "spotify", Name: "Spotify", Provider: spotifyProv})
} else {
fmt.Fprintln(os.Stderr, "Spotify is unavailable in this Windows build.")
}
}
if scProv := soundcloud.NewFromConfig(soundcloud.Config{
+8 -6
View File
@@ -4,6 +4,7 @@ package playlist
import (
"math/rand"
"net/url"
pathpkg "path"
"path/filepath"
"slices"
"strings"
@@ -108,7 +109,7 @@ func IsM3U(path string) bool {
if err != nil {
return false
}
ext := strings.ToLower(filepath.Ext(u.Path))
ext := strings.ToLower(pathpkg.Ext(u.Path))
return ext == ".m3u" || ext == ".m3u8"
}
ext := strings.ToLower(filepath.Ext(path))
@@ -127,7 +128,7 @@ func IsPLS(path string) bool {
if err != nil {
return false
}
return strings.ToLower(filepath.Ext(u.Path)) == ".pls"
return strings.ToLower(pathpkg.Ext(u.Path)) == ".pls"
}
return strings.ToLower(filepath.Ext(path)) == ".pls"
}
@@ -243,7 +244,7 @@ func IsFeed(path string) bool {
if err != nil {
return false
}
ext := strings.ToLower(filepath.Ext(u.Path))
ext := strings.ToLower(pathpkg.Ext(u.Path))
return ext == ".xml" || ext == ".rss" || ext == ".atom"
}
@@ -268,10 +269,11 @@ func trackFromURL(rawURL string) Track {
return t
}
// Extract filename from URL path
base := filepath.Base(u.Path)
// Extract filename from URL path using slash semantics, not OS-specific
// filepath rules. URL paths always use '/'.
base := pathpkg.Base(u.Path)
if base != "" && base != "." && base != "/" {
name := strings.TrimSuffix(base, filepath.Ext(base))
name := strings.TrimSuffix(base, pathpkg.Ext(base))
if name != "" && name != "stream" && name != "rest" {
t.Title = name
return t
+49 -2
View File
@@ -4,6 +4,7 @@ import (
"bufio"
"io"
"os"
pathpkg "path"
"path/filepath"
"strconv"
"strings"
@@ -72,8 +73,8 @@ func parseM3U(r io.Reader, baseDir string) ([]m3uEntry, error) {
// This is a path/URL line.
path := line
if baseDir != "" && !playlist.IsURL(path) && !filepath.IsAbs(path) {
path = filepath.Join(baseDir, path)
if baseDir != "" && !playlist.IsURL(path) {
path = resolveM3UPath(baseDir, path)
}
if pending != nil {
@@ -134,3 +135,49 @@ func resolveLocalM3U(path string) ([]playlist.Track, error) {
}
return entriesToTracks(entries), nil
}
// resolveM3UPath resolves an M3U entry path against a base directory.
// It returns the entry unchanged for URLs or empty paths.
// It checks for explicit Windows or POSIX absolute paths via
// isWindowsAbsolutePath and isPOSIXAbsolutePath, falling back to filepath.IsAbs.
// If usesWindowsPathSemantics(baseDir) is true, it resolves the path with Windows
// filepath semantics (filepath.Join and filepath.FromSlash); otherwise, it uses
// POSIX semantics via pathpkg.Join.
func resolveM3UPath(baseDir, entry string) string {
if entry == "" || playlist.IsURL(entry) {
return entry
}
if isWindowsAbsolutePath(entry) {
return filepath.Clean(entry)
}
if isPOSIXAbsolutePath(entry) {
return pathpkg.Clean(entry)
}
if filepath.IsAbs(entry) {
return filepath.Clean(entry)
}
if usesWindowsPathSemantics(baseDir) {
return filepath.Join(baseDir, filepath.FromSlash(entry))
}
return pathpkg.Join(baseDir, entry)
}
func usesWindowsPathSemantics(base string) bool {
return strings.Contains(base, `\`) || hasWindowsDrivePrefix(base)
}
func isPOSIXAbsolutePath(path string) bool {
return strings.HasPrefix(path, "/")
}
func isWindowsAbsolutePath(path string) bool {
return strings.HasPrefix(path, `\\`) || hasWindowsDrivePrefix(path)
}
func hasWindowsDrivePrefix(path string) bool {
if len(path) < 2 || path[1] != ':' {
return false
}
c := path[0]
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
}
+44
View File
@@ -1,6 +1,7 @@
package resolve
import (
"path/filepath"
"strings"
"testing"
)
@@ -186,3 +187,46 @@ func TestM3UEntryToTrackFile(t *testing.T) {
t.Errorf("DurationSecs = %d, want 180", tr.DurationSecs)
}
}
func TestResolveM3UPathWindows(t *testing.T) {
tests := []struct {
name string
baseDir string
entry string
want string
}{
{
name: "relative backslash",
baseDir: `C:\Music`,
entry: `artist\song.mp3`,
want: filepath.Clean(filepath.Join(`C:\Music`, filepath.FromSlash(`artist\song.mp3`))),
},
{
name: "relative forward slash",
baseDir: `C:\Music`,
entry: `artist/song.mp3`,
want: filepath.Clean(filepath.Join(`C:\Music`, filepath.FromSlash(`artist/song.mp3`))),
},
{
name: "absolute drive-letter",
baseDir: `C:\Music`,
entry: `D:\Other\track.mp3`,
want: filepath.Clean(`D:\Other\track.mp3`),
},
{
name: "absolute UNC path",
baseDir: `\\server\share`,
entry: `sub\file.mp3`,
want: filepath.Clean(filepath.Join(`\\server\share`, filepath.FromSlash(`sub\file.mp3`))),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveM3UPath(tt.baseDir, tt.entry)
if got != tt.want {
t.Fatalf("resolveM3UPath(%q, %q) = %q, want %q", tt.baseDir, tt.entry, got, tt.want)
}
})
}
}
+5 -5
View File
@@ -1479,7 +1479,7 @@
</div>
</div>
<div class="install-note">
Or build from source — <a href="https://github.com/bjarneo/cliamp">see the README</a>.
Or build from source — <a href="https://github.com/bjarneo/cliamp">see the README</a>. Windows builds are available from Releases; when <code>HOME</code> is unset, config lives under <code>%APPDATA%\cliamp</code>.
</div>
<div class="next-step">
@@ -1510,7 +1510,7 @@
<div class="source" style="--src-color:#1db954">
<div class="source-badge">OAuth · cached</div>
<div class="source-name">Spotify</div>
<div class="source-desc">Stream your Premium library. Bring your own developer <code>client_id</code> for a private quota, or use the built-in shared one. Search &amp; add tracks with <kbd>F</kbd>.</div>
<div class="source-desc">Stream your Premium library. Bring your own developer <code>client_id</code> for a private quota, or use the built-in shared one. Search &amp; add tracks with <kbd>F</kbd>. Currently unavailable on Windows builds.</div>
</div>
<div class="source" style="--src-color:#ff0000">
<div class="source-badge">yt-dlp</div>
@@ -1683,7 +1683,7 @@ user_id = "your-account-user-id"</code></pre>
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-name">Remote Control</div>
<p>Control a running instance from another terminal via Unix-socket IPC. Run with <code>--daemon</code> for headless playback driven entirely by scripts or Waybar.</p>
<p>Control a running instance from another terminal via local-socket IPC. Run with <code>--daemon</code> for headless playback driven entirely by scripts or Waybar.</p>
</div>
<div class="feature">
<div class="feature-icon"></div>
@@ -2071,7 +2071,7 @@ user_id = "your-account-user-id"</code></pre>
</div>
<p class="cli-remote-intro">
CLI flags override any config option for a single session.
Remote commands control a running instance over Unix-socket IPC — open another terminal and talk to <code>cliamp</code> directly.
Remote commands control a running instance over local-socket IPC — open another terminal and talk to <code>cliamp</code> directly.
</p>
<div class="cli-remote-grid">
@@ -2248,7 +2248,7 @@ p:<span class="fn">on</span>(<span class="str">"track.change"</span>, <span clas
</div>
<div class="plugin-api">
<div class="plugin-api-name">fs</div>
<p>Sandboxed I/O: <code>write()</code>, <code>read()</code>, <code>append()</code>, <code>remove()</code>, <code>exists()</code>, <code>mkdir()</code>, <code>listdir()</code></p>
<p>Sandboxed I/O: <code>write()</code>, <code>read()</code>, <code>append()</code>, <code>remove()</code>, <code>exists()</code>, <code>mkdir()</code>, <code>listdir()</code>. Writes are restricted to the system temp directory (<code>/tmp/</code> on Unix), <code>~/.config/cliamp/</code>, <code>~/.local/share/cliamp/</code>, and <code>~/Music/cliamp/</code> (on Windows, if <code>HOME</code> is unset, the config directory resolves to <code>%APPDATA%\cliamp</code>).</p>
</div>
<div class="plugin-api">
<div class="plugin-api-name">exec</div>
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
@@ -120,8 +121,8 @@ func TestDownloadAndReplace(t *testing.T) {
if err != nil {
t.Fatalf("Stat: %v", err)
}
// Verify executable bit is set (0o755).
if info.Mode().Perm()&0o111 == 0 {
// Verify executable bit is set where the platform models one.
if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 {
t.Errorf("target mode = %o, want executable", info.Mode().Perm())
}
}