From 60ba86e8ef35d3ddfd9f41715869ba4c3cf289fd Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Tue, 13 Oct 2026 09:31:14 +0300 Subject: [PATCH 01/22] feat(installer): spotify-login with PKCE, loopback and a paste file Signed-off-by: NovusEdge --- installer/internal/spotify/login.go | 237 ++++++++++++++++++++ installer/internal/spotify/login_test.go | 264 +++++++++++++++++++++++ installer/internal/spotify/tokens.go | 138 ++++++++++++ installer/main.go | 16 +- 4 files changed, 650 insertions(+), 5 deletions(-) create mode 100644 installer/internal/spotify/login.go create mode 100644 installer/internal/spotify/login_test.go create mode 100644 installer/internal/spotify/tokens.go diff --git a/installer/internal/spotify/login.go b/installer/internal/spotify/login.go new file mode 100644 index 00000000..d5b4ebcb --- /dev/null +++ b/installer/internal/spotify/login.go @@ -0,0 +1,237 @@ +package spotify + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "flag" + "fmt" + "html" + "io" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "regexp" + "runtime" + "strings" + "time" +) + +const AuthURL = "https://accounts.spotify.com/authorize" + +// Scopes cover the player, its lists and liking (user-library-modify). +const Scopes = "user-read-playback-state user-modify-playback-state user-read-currently-playing user-read-recently-played playlist-read-private playlist-read-collaborative user-library-read user-library-modify" + +var clientIDPattern = regexp.MustCompile(`^[0-9a-f]{32}$`) + +func randomString(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +// challenge is PKCE's S256 code challenge for verifier (RFC 7636 section 4.2). +func challenge(verifier string) string { + s := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(s[:]) +} + +// AuthorizeURL is the page the person agrees on; Spotify then redirects to redirect with a code. +func AuthorizeURL(clientID, redirect, codeChallenge, state string) string { + q := url.Values{ + "client_id": {clientID}, + "response_type": {"code"}, + "redirect_uri": {redirect}, + "code_challenge_method": {"S256"}, + "code_challenge": {codeChallenge}, + "state": {state}, + "scope": {Scopes}, + } + return AuthURL + "?" + q.Encode() +} + +// Login waits for Spotify's redirect, on the loopback listener or as an address written to +// PasteFile (for a browser that cannot reach this machine's 127.0.0.1), and exchanges its code. +type Login struct { + Auth Auth + ClientID string + Out string + PasteFile string + Listener net.Listener + Open func(string) error + // Notice gets the authorize URL as its first line, then one line per rejected paste. + Notice io.Writer + Poll time.Duration +} + +type callback struct { + q url.Values + done chan error +} + +const pageDone = "glowup

Connected. You can close this tab.

" + +func (l Login) Run(ctx context.Context) error { + redirect := fmt.Sprintf("http://127.0.0.1:%d/callback", l.Listener.Addr().(*net.TCPAddr).Port) + verifier, state := randomString(64), randomString(16) + auth := AuthorizeURL(l.ClientID, redirect, challenge(verifier), state) + fmt.Fprintln(l.Notice, auth) + // the URL is already printed, so a browser that will not open is not an error + _ = l.Open(auth) + + got := make(chan callback) + srv := &http.Server{ReadHeaderTimeout: 10 * time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/callback" { + http.NotFound(w, r) + return + } + q := r.URL.Query() + if q.Get("state") != state { + http.Error(w, "This address is not from glowup's current login. Start it again from Claude Code.", http.StatusBadRequest) + return + } + cb := callback{q: q, done: make(chan error, 1)} + select { + case got <- cb: + case <-r.Context().Done(): + return + case <-ctx.Done(): + http.Error(w, "glowup stopped waiting for this login.", http.StatusGone) + return + } + if err := <-cb.done; err != nil { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "glowup

glowup could not connect: %s

", html.EscapeString(err.Error())) + return + } + _, _ = io.WriteString(w, pageDone) + })} + go func() { _ = srv.Serve(l.Listener) }() + defer func() { + sctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = srv.Shutdown(sctx) + }() + + if l.PasteFile != "" { + go l.watchPaste(ctx, state, got) + } + for { + select { + case <-ctx.Done(): + return errors.New("timed out waiting for the browser") + case cb := <-got: + err := l.finish(ctx, cb.q, redirect, verifier) + cb.done <- err + return err + } + } +} + +// watchPaste hands on an address written to PasteFile, and says why when it is not this login's. +func (l Login) watchPaste(ctx context.Context, state string, got chan<- callback) { + t := time.NewTicker(l.Poll) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + } + b, err := os.ReadFile(l.PasteFile) + if err != nil { + continue + } + _ = os.Remove(l.PasteFile) + u, err := url.Parse(strings.TrimSpace(string(b))) + if err != nil || u.Query().Get("state") != state { + fmt.Fprintln(l.Notice, "That address is not from this login. Paste the address the browser shows after you agree.") + continue + } + select { + case got <- callback{q: u.Query(), done: make(chan error, 1)}: + case <-ctx.Done(): + } + return + } +} + +func (l Login) finish(ctx context.Context, q url.Values, redirect, verifier string) error { + if e := q.Get("error"); e != "" { + return fmt.Errorf("Spotify said: %s", e) + } + code := q.Get("code") + if code == "" { + return errors.New("the redirect carried no code") + } + tr, err := l.Auth.post(ctx, url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {redirect}, + "client_id": {l.ClientID}, + "code_verifier": {verifier}, + }) + if err != nil { + return err + } + now := l.Auth.Now() + return WriteTokens(l.Out, Tokens{ + Format: TokensFormat, ClientID: l.ClientID, AccessToken: tr.AccessToken, RefreshToken: tr.RefreshToken, + ExpiresAt: now.Add(time.Duration(tr.ExpiresIn) * time.Second).UnixMilli(), Scope: tr.Scope, ConnectedAt: now.UnixMilli(), Gen: 1, + }) +} + +// openBrowser leaves the child's output unconnected: stdout is the mod's channel. +func openBrowser(u string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", u) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", u) + default: + cmd = exec.Command("xdg-open", u) + } + return cmd.Start() +} + +// LoginMain runs `glowup-installer spotify-login --client-id ID --out FILE [--paste-file FILE] [--timeout D]`. +func LoginMain(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("glowup-installer spotify-login", flag.ContinueOnError) + fs.SetOutput(stderr) + id := fs.String("client-id", "", "the Client ID of your Spotify app") + out := fs.String("out", "", "the token file to write") + paste := fs.String("paste-file", "", "a file glowup writes a pasted redirect address to") + wait := fs.Duration("timeout", 5*time.Minute, "how long to wait for the browser") + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + if !clientIDPattern.MatchString(*id) || *out == "" || fs.NArg() != 0 { + fmt.Fprintln(stderr, "usage: glowup-installer spotify-login --client-id ID --out FILE [--paste-file FILE] (ID is 32 hex characters)") + return 2 + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + fmt.Fprintln(stderr, "glowup-installer spotify-login:", err) + return 1 + } + ctx, cancel := context.WithTimeout(context.Background(), *wait) + defer cancel() + err = Login{ + Auth: Auth{Client: &http.Client{}, TokenURL: TokenURL, Now: time.Now}, + ClientID: *id, Out: *out, PasteFile: *paste, Listener: ln, + Open: openBrowser, Notice: stdout, Poll: 500 * time.Millisecond, + }.Run(ctx) + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + return 0 +} diff --git a/installer/internal/spotify/login_test.go b/installer/internal/spotify/login_test.go new file mode 100644 index 00000000..321e1ba1 --- /dev/null +++ b/installer/internal/spotify/login_test.go @@ -0,0 +1,264 @@ +package spotify + +import ( + "bufio" + "context" + "encoding/json" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +const testClientID = "0123456789abcdef0123456789abcdef" + +// tokenServer answers the token endpoint and records each form it got. +type tokenServer struct { + mu sync.Mutex + forms []url.Values + reply func(form url.Values) (int, string) +} + +func (s *tokenServer) start(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + s.mu.Lock() + s.forms = append(s.forms, r.PostForm) + s.mu.Unlock() + code, body := s.reply(r.PostForm) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _, _ = io.WriteString(w, body) + })) + t.Cleanup(srv.Close) + return srv +} + +func (s *tokenServer) calls() int { s.mu.Lock(); defer s.mu.Unlock(); return len(s.forms) } + +// form reads under the lock: the race detector sees no ordering through the HTTP round trip. +func (s *tokenServer) form(i int) url.Values { s.mu.Lock(); defer s.mu.Unlock(); return s.forms[i] } + +const grant = `{"access_token":"at-1","token_type":"Bearer","scope":"user-read-playback-state","expires_in":3600,"refresh_token":"rt-1"}` + +// startLogin runs a Login in the background and returns the authorize URL it printed, its notice lines and its result. +func startLogin(t *testing.T, srv *httptest.Server, dir string, wait time.Duration) (*url.URL, *bufio.Scanner, chan error, int) { + t.Helper() + // the paste file sits beside the token file, and Login creates nothing before its exchange + if err := os.MkdirAll(filepath.Join(dir, "glowup"), 0o700); err != nil { + t.Fatal(err) + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + pr, pw := io.Pipe() + ctx, cancel := context.WithTimeout(context.Background(), wait) + t.Cleanup(cancel) + done := make(chan error, 1) + l := Login{ + Auth: Auth{Client: srv.Client(), TokenURL: srv.URL, Now: func() time.Time { return time.UnixMilli(1_000_000) }}, + ClientID: testClientID, + Out: filepath.Join(dir, "glowup", "spotify.json"), + PasteFile: filepath.Join(dir, "glowup", "spotify-paste"), + Listener: ln, + Open: func(string) error { return nil }, + Notice: pw, + Poll: 20 * time.Millisecond, + } + go func() { done <- l.Run(ctx); pw.Close() }() + lines := bufio.NewScanner(pr) + if !lines.Scan() { + t.Fatal("no authorize URL printed") + } + u, err := url.Parse(lines.Text()) + if err != nil { + t.Fatal(err) + } + return u, lines, done, ln.Addr().(*net.TCPAddr).Port +} + +func TestChallengeMatchesRFC7636(t *testing.T) { + if got := challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"); got != "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" { + t.Fatalf("challenge = %s", got) + } +} + +func TestAuthorizeURL(t *testing.T) { + u, _ := url.Parse(AuthorizeURL(testClientID, "http://127.0.0.1:5000/callback", "ch", "st")) + q := u.Query() + if u.Host != "accounts.spotify.com" || u.Path != "/authorize" { + t.Fatalf("url = %s", u) + } + want := map[string]string{"client_id": testClientID, "response_type": "code", "redirect_uri": "http://127.0.0.1:5000/callback", "code_challenge_method": "S256", "code_challenge": "ch", "state": "st", "scope": Scopes} + for k, v := range want { + if q.Get(k) != v { + t.Errorf("%s = %q, want %q", k, q.Get(k), v) + } + } +} + +func TestLoginLoopback(t *testing.T) { + ts := &tokenServer{reply: func(url.Values) (int, string) { return 200, grant }} + srv := ts.start(t) + dir := t.TempDir() + auth, _, done, port := startLogin(t, srv, dir, 5*time.Second) + q := auth.Query() + if q.Get("redirect_uri") != "http://127.0.0.1:"+itoa(port)+"/callback" { + t.Fatalf("redirect_uri = %s", q.Get("redirect_uri")) + } + // a request with another state is refused and the login keeps waiting + bad, err := http.Get("http://127.0.0.1:" + itoa(port) + "/callback?code=x&state=nope") + if err != nil { + t.Fatal(err) + } + bad.Body.Close() + if bad.StatusCode != 400 { + t.Fatalf("wrong state answered %d", bad.StatusCode) + } + resp, err := http.Get("http://127.0.0.1:" + itoa(port) + "/callback?code=the-code&state=" + url.QueryEscape(q.Get("state"))) + if err != nil { + t.Fatal(err) + } + page, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != 200 || !strings.Contains(string(page), "Connected") { + t.Fatalf("page %d %s", resp.StatusCode, page) + } + if err := <-done; err != nil { + t.Fatal(err) + } + form := ts.form(0) + if form.Get("grant_type") != "authorization_code" || form.Get("code") != "the-code" || form.Get("client_id") != testClientID || form.Get("redirect_uri") != q.Get("redirect_uri") { + t.Fatalf("form = %v", form) + } + if challenge(form.Get("code_verifier")) != q.Get("code_challenge") || len(form.Get("code_verifier")) != 86 { + t.Fatalf("verifier does not match the challenge") + } + tok, err := ReadTokens(filepath.Join(dir, "glowup", "spotify.json")) + if err != nil { + t.Fatal(err) + } + if tok.AccessToken != "at-1" || tok.RefreshToken != "rt-1" || tok.ClientID != testClientID || tok.ExpiresAt != 1_000_000+3_600_000 || tok.ConnectedAt != 1_000_000 || tok.Gen != 1 || tok.Format != TokensFormat { + t.Fatalf("tokens = %+v", tok) + } + if runtime.GOOS != "windows" { + if st, _ := os.Stat(filepath.Join(dir, "glowup", "spotify.json")); st.Mode().Perm() != 0o600 { + t.Fatalf("mode %v", st.Mode().Perm()) + } + } +} + +func TestLoginPaste(t *testing.T) { + ts := &tokenServer{reply: func(url.Values) (int, string) { return 200, grant }} + srv := ts.start(t) + dir := t.TempDir() + auth, _, done, port := startLogin(t, srv, dir, 5*time.Second) + paste := filepath.Join(dir, "glowup", "spotify-paste") + addr := "http://127.0.0.1:" + itoa(port) + "/callback?code=pasted&state=" + url.QueryEscape(auth.Query().Get("state")) + if err := os.WriteFile(paste, []byte(addr+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatal(err) + } + if ts.form(0).Get("code") != "pasted" { + t.Fatalf("form = %v", ts.form(0)) + } + if _, err := os.Stat(paste); !os.IsNotExist(err) { + t.Fatal("the paste file was left behind") + } +} + +func TestLoginPasteWrongState(t *testing.T) { + ts := &tokenServer{reply: func(url.Values) (int, string) { return 200, grant }} + srv := ts.start(t) + dir := t.TempDir() + auth, lines, done, port := startLogin(t, srv, dir, 5*time.Second) + paste := filepath.Join(dir, "glowup", "spotify-paste") + if err := os.WriteFile(paste, []byte("https://example.com/not-it"), 0o600); err != nil { + t.Fatal(err) + } + if !lines.Scan() || !strings.Contains(lines.Text(), "not from this login") { + t.Fatalf("notice = %q", lines.Text()) + } + addr := "http://127.0.0.1:" + itoa(port) + "/callback?code=ok&state=" + url.QueryEscape(auth.Query().Get("state")) + _ = os.WriteFile(paste, []byte(addr), 0o600) + if err := <-done; err != nil { + t.Fatal(err) + } + if ts.calls() != 1 { + t.Fatalf("token calls = %d", ts.calls()) + } +} + +func TestLoginRefused(t *testing.T) { + ts := &tokenServer{reply: func(url.Values) (int, string) { return 200, grant }} + srv := ts.start(t) + auth, _, done, port := startLogin(t, srv, t.TempDir(), 5*time.Second) + resp, err := http.Get("http://127.0.0.1:" + itoa(port) + "/callback?error=access_denied&state=" + url.QueryEscape(auth.Query().Get("state"))) + if err == nil { + resp.Body.Close() + } + if err := <-done; err == nil || !strings.Contains(err.Error(), "access_denied") { + t.Fatalf("err = %v", err) + } + if ts.calls() != 0 { + t.Fatal("a refused login exchanged a code") + } +} + +func TestLoginExchangeError(t *testing.T) { + ts := &tokenServer{reply: func(url.Values) (int, string) { + return 400, `{"error":"invalid_client","error_description":"Invalid client"}` + }} + srv := ts.start(t) + auth, _, done, port := startLogin(t, srv, t.TempDir(), 5*time.Second) + resp, err := http.Get("http://127.0.0.1:" + itoa(port) + "/callback?code=c&state=" + url.QueryEscape(auth.Query().Get("state"))) + if err == nil { + resp.Body.Close() + } + if err := <-done; err == nil || !strings.Contains(err.Error(), "Invalid client") { + t.Fatalf("err = %v", err) + } +} + +func TestLoginTimesOut(t *testing.T) { + ts := &tokenServer{reply: func(url.Values) (int, string) { return 200, grant }} + srv := ts.start(t) + _, _, done, _ := startLogin(t, srv, t.TempDir(), 100*time.Millisecond) + if err := <-done; err == nil || !strings.Contains(err.Error(), "timed out") { + t.Fatalf("err = %v", err) + } +} + +func TestLoginMainRefusesABadClientID(t *testing.T) { + var errb strings.Builder + if code := LoginMain([]string{"--client-id", "not-hex", "--out", filepath.Join(t.TempDir(), "s.json")}, io.Discard, &errb); code != 2 { + t.Fatalf("exit %d", code) + } + if !strings.Contains(errb.String(), "32 hex") { + t.Fatalf("stderr = %q", errb.String()) + } +} + +func TestReadTokensNewerFormat(t *testing.T) { + p := filepath.Join(t.TempDir(), "spotify.json") + b, _ := json.Marshal(map[string]any{"format": TokensFormat + 1, "accessToken": "x"}) + _ = os.WriteFile(p, b, 0o600) + if _, err := ReadTokens(p); err == nil || !strings.Contains(err.Error(), "newer") { + t.Fatalf("err = %v", err) + } +} + +func itoa(n int) string { return strconv.Itoa(n) } diff --git a/installer/internal/spotify/tokens.go b/installer/internal/spotify/tokens.go new file mode 100644 index 00000000..b3e8f765 --- /dev/null +++ b/installer/internal/spotify/tokens.go @@ -0,0 +1,138 @@ +package spotify + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + TokenURL = "https://accounts.spotify.com/api/token" + // The token file format this build reads and writes; the mod refuses a newer one too. + TokensFormat = 1 +) + +// Tokens is /glowup/spotify.json. Gen counts refreshes, so a refresh that lost a +// race to another session can tell from the file alone, without comparing secrets. +type Tokens struct { + Format int `json:"format"` + ClientID string `json:"clientId"` + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresAt int64 `json:"expiresAt"` + Scope string `json:"scope"` + ConnectedAt int64 `json:"connectedAt"` + Gen int `json:"gen"` +} + +// ReadTokens reads the token file and refuses one a newer glowup wrote. +func ReadTokens(path string) (Tokens, error) { + var t Tokens + b, err := os.ReadFile(path) + if err != nil { + return t, err + } + if err := json.Unmarshal(b, &t); err != nil { + return t, fmt.Errorf("%s does not parse: %w", path, err) + } + if t.Format > TokensFormat { + return t, fmt.Errorf("%s was saved by a newer glowup", path) + } + return t, nil +} + +// WriteTokens replaces the file through a temporary file and a rename, so a reader never sees half of it. +// CreateTemp makes the file 0600. MkdirAll makes a missing directory 0700 and leaves an existing one +// (/glowup, which A1 already writes to) as it is. +func WriteTokens(path string, t Tokens) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + b, err := json.Marshal(t) + if err != nil { + return err + } + f, err := os.CreateTemp(dir, ".spotify-*.json") + if err != nil { + return err + } + if _, err := f.Write(b); err != nil { + f.Close() + os.Remove(f.Name()) + return err + } + if err := f.Close(); err != nil { + os.Remove(f.Name()) + return err + } + return os.Rename(f.Name(), path) +} + +// Auth talks to Spotify's token endpoint; TokenURL and Now are fields so tests can stand in. +type Auth struct { + Client *http.Client + TokenURL string + Now func() time.Time +} + +type tokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int64 `json:"expires_in"` + Scope string `json:"scope"` + Error string `json:"error"` + Description string `json:"error_description"` +} + +// GrantError is the token endpoint's refusal; Code is OAuth's error code, such as invalid_grant. +type GrantError struct { + Status int + Code, Description string +} + +func (e *GrantError) Error() string { + if e.Description != "" { + return "Spotify refused: " + e.Description + } + if e.Code != "" { + return "Spotify refused: " + e.Code + } + return fmt.Sprintf("Spotify's token endpoint answered %d", e.Status) +} + +func (a Auth) post(ctx context.Context, form url.Values) (tokenResponse, error) { + var tr tokenResponse + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.TokenURL, strings.NewReader(form.Encode())) + if err != nil { + return tr, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := a.Client.Do(req) + if err != nil { + return tr, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return tr, err + } + _ = json.Unmarshal(body, &tr) + if resp.StatusCode != http.StatusOK || tr.Error != "" { + return tr, &GrantError{Status: resp.StatusCode, Code: tr.Error, Description: tr.Description} + } + if tr.AccessToken == "" { + return tr, errors.New("Spotify's token endpoint sent no access token") + } + return tr, nil +} diff --git a/installer/main.go b/installer/main.go index 2db7659d..c61cb9ac 100644 --- a/installer/main.go +++ b/installer/main.go @@ -1,7 +1,8 @@ // Command glowup-installer installs the glowup mod into Claude Code. // install.sh (docs/web/public) downloads and runs it; it is not installed on PATH. // It also runs glowup's config screen as `glowup-installer config`, which -// /glowup config launches, and `spotify-art`, which the Music tab runs for a cover. +// /glowup config launches, `spotify-art`, which the Music tab runs for a cover, and +// `spotify-login`, which connects the music player to a Spotify account. package main import ( @@ -9,6 +10,7 @@ import ( "errors" "flag" "fmt" + "io" "os" "os/signal" @@ -29,11 +31,15 @@ func main() { os.Exit(run()) } func run() int { // a subcommand: cli.Parse refuses positional arguments - if len(os.Args) > 1 && os.Args[1] == "config" { - return configtui.Main(os.Args[2:], os.Stdout, os.Stderr) + subs := map[string]func([]string, io.Writer, io.Writer) int{ + "config": configtui.Main, + "spotify-art": spotify.ArtMain, + "spotify-login": spotify.LoginMain, } - if len(os.Args) > 1 && os.Args[1] == "spotify-art" { - return spotify.ArtMain(os.Args[2:], os.Stdout, os.Stderr) + if len(os.Args) > 1 { + if sub, ok := subs[os.Args[1]]; ok { + return sub(os.Args[2:], os.Stdout, os.Stderr) + } } o, err := cli.Parse(os.Args[1:], os.Stderr) if errors.Is(err, flag.ErrHelp) { From 51ca5c456014170ab7e97cf77060255ccd5ad4ca Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Tue, 13 Oct 2026 09:33:51 +0300 Subject: [PATCH 02/22] fix(installer): spotify token write and login exit Signed-off-by: NovusEdge --- installer/internal/spotify/login.go | 24 +++++++----- installer/internal/spotify/login_test.go | 49 +++++++++++++++++++++++- installer/internal/spotify/tokens.go | 11 +++++- 3 files changed, 72 insertions(+), 12 deletions(-) diff --git a/installer/internal/spotify/login.go b/installer/internal/spotify/login.go index d5b4ebcb..92a11963 100644 --- a/installer/internal/spotify/login.go +++ b/installer/internal/spotify/login.go @@ -84,6 +84,8 @@ func (l Login) Run(ctx context.Context) error { _ = l.Open(auth) got := make(chan callback) + // Run takes one callback; a later one would block on got until Shutdown gave up on it. + finished := make(chan struct{}) srv := &http.Server{ReadHeaderTimeout: 10 * time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/callback" { http.NotFound(w, r) @@ -102,6 +104,9 @@ func (l Login) Run(ctx context.Context) error { case <-ctx.Done(): http.Error(w, "glowup stopped waiting for this login.", http.StatusGone) return + case <-finished: + http.Error(w, "This login already finished.", http.StatusGone) + return } if err := <-cb.done; err != nil { w.WriteHeader(http.StatusBadRequest) @@ -112,6 +117,7 @@ func (l Login) Run(ctx context.Context) error { })} go func() { _ = srv.Serve(l.Listener) }() defer func() { + close(finished) sctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() _ = srv.Shutdown(sctx) @@ -120,15 +126,13 @@ func (l Login) Run(ctx context.Context) error { if l.PasteFile != "" { go l.watchPaste(ctx, state, got) } - for { - select { - case <-ctx.Done(): - return errors.New("timed out waiting for the browser") - case cb := <-got: - err := l.finish(ctx, cb.q, redirect, verifier) - cb.done <- err - return err - } + select { + case <-ctx.Done(): + return errors.New("timed out waiting for the browser") + case cb := <-got: + err := l.finish(ctx, cb.q, redirect, verifier) + cb.done <- err + return err } } @@ -214,7 +218,7 @@ func LoginMain(args []string, stdout, stderr io.Writer) int { return 2 } if !clientIDPattern.MatchString(*id) || *out == "" || fs.NArg() != 0 { - fmt.Fprintln(stderr, "usage: glowup-installer spotify-login --client-id ID --out FILE [--paste-file FILE] (ID is 32 hex characters)") + fmt.Fprintln(stderr, "usage: glowup-installer spotify-login --client-id ID --out FILE [--paste-file FILE] [--timeout D] (ID is 32 hex characters)") return 2 } ln, err := net.Listen("tcp", "127.0.0.1:0") diff --git a/installer/internal/spotify/login_test.go b/installer/internal/spotify/login_test.go index 321e1ba1..b67ca2cc 100644 --- a/installer/internal/spotify/login_test.go +++ b/installer/internal/spotify/login_test.go @@ -193,7 +193,9 @@ func TestLoginPasteWrongState(t *testing.T) { t.Fatalf("notice = %q", lines.Text()) } addr := "http://127.0.0.1:" + itoa(port) + "/callback?code=ok&state=" + url.QueryEscape(auth.Query().Get("state")) - _ = os.WriteFile(paste, []byte(addr), 0o600) + if err := os.WriteFile(paste, []byte(addr), 0o600); err != nil { + t.Fatal(err) + } if err := <-done; err != nil { t.Fatal(err) } @@ -242,6 +244,51 @@ func TestLoginTimesOut(t *testing.T) { } } +func TestLoginSecondCallbackDoesNotHoldTheShutdown(t *testing.T) { + ts := &tokenServer{reply: func(url.Values) (int, string) { + time.Sleep(300 * time.Millisecond) + return 200, grant + }} + srv := ts.start(t) + auth, _, done, port := startLogin(t, srv, t.TempDir(), 10*time.Second) + cb := "http://127.0.0.1:" + itoa(port) + "/callback?code=c&state=" + url.QueryEscape(auth.Query().Get("state")) + start := time.Now() + go func() { + if resp, err := http.Get(cb); err == nil { + resp.Body.Close() + } + }() + time.Sleep(100 * time.Millisecond) + go func() { + if resp, err := http.Get(cb); err == nil { + resp.Body.Close() + } + }() + if err := <-done; err != nil { + t.Fatal(err) + } + // Shutdown waits up to 2s for a handler still blocked on the first run's channel + if d := time.Since(start); d > 1500*time.Millisecond { + t.Fatalf("Run took %v", d) + } +} + +func TestWriteTokensLeavesNoTempFileOnFailure(t *testing.T) { + dir := t.TempDir() + // a non-empty directory at the target makes the rename fail + target := filepath.Join(dir, "spotify.json") + if err := os.MkdirAll(filepath.Join(target, "keep"), 0o700); err != nil { + t.Fatal(err) + } + if err := WriteTokens(target, Tokens{Format: TokensFormat, AccessToken: "x"}); err == nil { + t.Fatal("expected the rename to fail") + } + left, _ := filepath.Glob(filepath.Join(dir, ".spotify-*")) + if len(left) != 0 { + t.Fatalf("temp files left: %v", left) + } +} + func TestLoginMainRefusesABadClientID(t *testing.T) { var errb strings.Builder if code := LoginMain([]string{"--client-id", "not-hex", "--out", filepath.Join(t.TempDir(), "s.json")}, io.Discard, &errb); code != 2 { diff --git a/installer/internal/spotify/tokens.go b/installer/internal/spotify/tokens.go index b3e8f765..bc223e3d 100644 --- a/installer/internal/spotify/tokens.go +++ b/installer/internal/spotify/tokens.go @@ -70,11 +70,20 @@ func WriteTokens(path string, t Tokens) error { os.Remove(f.Name()) return err } + if err := f.Sync(); err != nil { + f.Close() + os.Remove(f.Name()) + return err + } if err := f.Close(); err != nil { os.Remove(f.Name()) return err } - return os.Rename(f.Name(), path) + if err := os.Rename(f.Name(), path); err != nil { + os.Remove(f.Name()) + return err + } + return nil } // Auth talks to Spotify's token endpoint; TokenURL and Now are fields so tests can stand in. From c06600a2c84fcddceb81088a3a9cda3a1a8a5ba1 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Tue, 13 Oct 2026 09:34:43 +0300 Subject: [PATCH 03/22] feat(installer): spotify-refresh behind a lock, and spotify-logout Signed-off-by: NovusEdge --- installer/internal/spotify/tokens.go | 121 +++++++++++++++++ installer/internal/spotify/tokens_test.go | 153 ++++++++++++++++++++++ installer/main.go | 11 +- 3 files changed, 281 insertions(+), 4 deletions(-) create mode 100644 installer/internal/spotify/tokens_test.go diff --git a/installer/internal/spotify/tokens.go b/installer/internal/spotify/tokens.go index bc223e3d..0bdebd6d 100644 --- a/installer/internal/spotify/tokens.go +++ b/installer/internal/spotify/tokens.go @@ -4,8 +4,10 @@ import ( "context" "encoding/json" "errors" + "flag" "fmt" "io" + "io/fs" "net/http" "net/url" "os" @@ -145,3 +147,122 @@ func (a Auth) post(ctx context.Context, form url.Values) (tokenResponse, error) } return tr, nil } + +// ErrInvalidGrant is Spotify refusing the refresh token: the person must connect again. +var ErrInvalidGrant = errors.New("Spotify refused the saved login; connect again") + +const ( + lockWait = 10 * time.Second + // A refresh takes seconds; a lock this old was left by a killed run. + lockStale = 30 * time.Second +) + +// lockFile is a portable exclusive lock: the standard library has no LockFileEx, and O_EXCL works everywhere. +func lockFile(path string) (func(), error) { + lock := path + ".lock" + deadline := time.Now().Add(lockWait) + for { + f, err := os.OpenFile(lock, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err == nil { + f.Close() + return func() { os.Remove(lock) }, nil + } + if !errors.Is(err, fs.ErrExist) { + return nil, err + } + if st, e := os.Stat(lock); e == nil && time.Since(st.ModTime()) > lockStale { + os.Remove(lock) + continue + } + if time.Now().After(deadline) { + return nil, errors.New("another refresh is still running") + } + time.Sleep(50 * time.Millisecond) + } +} + +// Refresh swaps the refresh token for a new access token, unless the file has moved past gen, +// which means another run refreshed it while the caller waited. +func Refresh(ctx context.Context, a Auth, path string, gen int) error { + unlock, err := lockFile(path) + if err != nil { + return err + } + defer unlock() + t, err := ReadTokens(path) + if err != nil { + return err + } + if t.Gen != gen { + return nil + } + tr, err := a.post(ctx, url.Values{"grant_type": {"refresh_token"}, "refresh_token": {t.RefreshToken}, "client_id": {t.ClientID}}) + var ge *GrantError + if errors.As(err, &ge) && ge.Code == "invalid_grant" { + return ErrInvalidGrant + } + if err != nil { + return err + } + t.AccessToken = tr.AccessToken + // Spotify may or may not rotate it + if tr.RefreshToken != "" { + t.RefreshToken = tr.RefreshToken + } + if tr.Scope != "" { + t.Scope = tr.Scope + } + t.ExpiresAt = a.Now().Add(time.Duration(tr.ExpiresIn) * time.Second).UnixMilli() + t.Gen++ + return WriteTokens(path, t) +} + +// RefreshMain runs `glowup-installer spotify-refresh --file FILE --if-gen N`. +func RefreshMain(args []string, stdout, stderr io.Writer) int { + fl := flag.NewFlagSet("glowup-installer spotify-refresh", flag.ContinueOnError) + fl.SetOutput(stderr) + file := fl.String("file", "", "the token file") + gen := fl.Int("if-gen", -1, "refresh only while the file holds this generation") + if err := fl.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + if *file == "" || *gen < 0 || fl.NArg() != 0 { + fmt.Fprintln(stderr, "usage: glowup-installer spotify-refresh --file FILE --if-gen N") + return 2 + } + err := Refresh(context.Background(), Auth{Client: &http.Client{}, TokenURL: TokenURL, Now: time.Now}, *file, *gen) + if errors.Is(err, ErrInvalidGrant) { + fmt.Fprintln(stderr, err) + return 3 + } + if err != nil { + fmt.Fprintln(stderr, "glowup-installer spotify-refresh:", err) + return 1 + } + return 0 +} + +// LogoutMain runs `glowup-installer spotify-logout --file FILE`: the helper stays the only writer of the secret file. +func LogoutMain(args []string, stdout, stderr io.Writer) int { + fl := flag.NewFlagSet("glowup-installer spotify-logout", flag.ContinueOnError) + fl.SetOutput(stderr) + file := fl.String("file", "", "the token file") + if err := fl.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + if *file == "" || fl.NArg() != 0 { + fmt.Fprintln(stderr, "usage: glowup-installer spotify-logout --file FILE") + return 2 + } + if err := os.Remove(*file); err != nil && !errors.Is(err, fs.ErrNotExist) { + fmt.Fprintln(stderr, "glowup-installer spotify-logout:", err) + return 1 + } + return 0 +} diff --git a/installer/internal/spotify/tokens_test.go b/installer/internal/spotify/tokens_test.go new file mode 100644 index 00000000..dcb0bbe5 --- /dev/null +++ b/installer/internal/spotify/tokens_test.go @@ -0,0 +1,153 @@ +package spotify + +import ( + "context" + "errors" + "io" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" +) + +func seed(t *testing.T, gen int) string { + t.Helper() + p := filepath.Join(t.TempDir(), "glowup", "spotify.json") + if err := WriteTokens(p, Tokens{Format: 1, ClientID: testClientID, AccessToken: "old-at", RefreshToken: "old-rt", ExpiresAt: 1, ConnectedAt: 1, Gen: gen}); err != nil { + t.Fatal(err) + } + return p +} + +func authFor(ts *tokenServer, t *testing.T) Auth { + srv := ts.start(t) + return Auth{Client: srv.Client(), TokenURL: srv.URL, Now: func() time.Time { return time.UnixMilli(5_000_000) }} +} + +func TestRefreshRotates(t *testing.T) { + ts := &tokenServer{reply: func(url.Values) (int, string) { + return 200, `{"access_token":"new-at","expires_in":3600,"refresh_token":"new-rt","scope":"s"}` + }} + p := seed(t, 4) + if err := Refresh(context.Background(), authFor(ts, t), p, 4); err != nil { + t.Fatal(err) + } + tok, _ := ReadTokens(p) + if tok.AccessToken != "new-at" || tok.RefreshToken != "new-rt" || tok.Gen != 5 || tok.ExpiresAt != 5_000_000+3_600_000 { + t.Fatalf("tokens = %+v", tok) + } + f := ts.form(0) + if f.Get("grant_type") != "refresh_token" || f.Get("refresh_token") != "old-rt" || f.Get("client_id") != testClientID { + t.Fatalf("form = %v", f) + } + if runtime.GOOS != "windows" { + if st, _ := os.Stat(p); st.Mode().Perm() != 0o600 { + t.Fatalf("mode %v", st.Mode().Perm()) + } + } +} + +func TestRefreshKeepsTheOldRefreshToken(t *testing.T) { + ts := &tokenServer{reply: func(url.Values) (int, string) { return 200, `{"access_token":"new-at","expires_in":3600}` }} + p := seed(t, 1) + if err := Refresh(context.Background(), authFor(ts, t), p, 1); err != nil { + t.Fatal(err) + } + if tok, _ := ReadTokens(p); tok.RefreshToken != "old-rt" || tok.AccessToken != "new-at" { + t.Fatalf("tokens = %+v", tok) + } +} + +func TestRefreshSkipsWhenTheGenerationMoved(t *testing.T) { + ts := &tokenServer{reply: func(url.Values) (int, string) { return 200, `{"access_token":"x","expires_in":3600}` }} + p := seed(t, 7) + if err := Refresh(context.Background(), authFor(ts, t), p, 6); err != nil { + t.Fatal(err) + } + if ts.calls() != 0 { + t.Fatal("refreshed a generation another run had already refreshed") + } +} + +func TestRefreshInvalidGrant(t *testing.T) { + ts := &tokenServer{reply: func(url.Values) (int, string) { + return 400, `{"error":"invalid_grant","error_description":"Refresh token revoked"}` + }} + p := seed(t, 1) + if err := Refresh(context.Background(), authFor(ts, t), p, 1); !errors.Is(err, ErrInvalidGrant) { + t.Fatalf("err = %v", err) + } + if tok, _ := ReadTokens(p); tok.AccessToken != "old-at" { + t.Fatal("a refused refresh rewrote the file") + } +} + +func TestRefreshConcurrentOneCall(t *testing.T) { + ts := &tokenServer{reply: func(url.Values) (int, string) { + time.Sleep(100 * time.Millisecond) + return 200, `{"access_token":"new-at","expires_in":3600,"refresh_token":"new-rt"}` + }} + p := seed(t, 2) + a := authFor(ts, t) + var wg sync.WaitGroup + errs := make([]error, 2) + for i := range errs { + wg.Add(1) + go func(i int) { defer wg.Done(); errs[i] = Refresh(context.Background(), a, p, 2) }(i) + } + wg.Wait() + if errs[0] != nil || errs[1] != nil { + t.Fatalf("errs = %v", errs) + } + if ts.calls() != 1 { + t.Fatalf("token calls = %d", ts.calls()) + } + if tok, _ := ReadTokens(p); tok.Gen != 3 || tok.RefreshToken != "new-rt" { + t.Fatalf("tokens = %+v", tok) + } +} + +func TestRefreshTakesAStaleLock(t *testing.T) { + ts := &tokenServer{reply: func(url.Values) (int, string) { return 200, `{"access_token":"new-at","expires_in":3600}` }} + p := seed(t, 1) + if err := os.WriteFile(p+".lock", nil, 0o600); err != nil { + t.Fatal(err) + } + old := time.Now().Add(-time.Minute) + if err := os.Chtimes(p+".lock", old, old); err != nil { + t.Fatal(err) + } + if err := Refresh(context.Background(), authFor(ts, t), p, 1); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(p + ".lock"); !os.IsNotExist(err) { + t.Fatal("the lock was left behind") + } +} + +func TestRefreshMainExitCodes(t *testing.T) { + var errb strings.Builder + if code := RefreshMain([]string{"--file", filepath.Join(t.TempDir(), "missing.json"), "--if-gen", "1"}, io.Discard, &errb); code != 1 { + t.Fatalf("missing file: exit %d", code) + } + if code := RefreshMain([]string{"--file", "x"}, io.Discard, io.Discard); code != 2 { + t.Fatalf("no --if-gen: exit %d", code) + } +} + +func TestLogout(t *testing.T) { + p := seed(t, 1) + if code := LogoutMain([]string{"--file", p}, io.Discard, io.Discard); code != 0 { + t.Fatalf("exit %d", code) + } + if _, err := os.Stat(p); !os.IsNotExist(err) { + t.Fatal("the token file is still there") + } + if code := LogoutMain([]string{"--file", p}, io.Discard, io.Discard); code != 0 { + t.Fatalf("second logout: exit %d", code) + } +} diff --git a/installer/main.go b/installer/main.go index c61cb9ac..a58ab188 100644 --- a/installer/main.go +++ b/installer/main.go @@ -2,7 +2,8 @@ // install.sh (docs/web/public) downloads and runs it; it is not installed on PATH. // It also runs glowup's config screen as `glowup-installer config`, which // /glowup config launches, `spotify-art`, which the Music tab runs for a cover, and -// `spotify-login`, which connects the music player to a Spotify account. +// `spotify-login`, `spotify-refresh` and `spotify-logout`, which connect the music player to a +// Spotify account and keep that login fresh. package main import ( @@ -32,9 +33,11 @@ func main() { os.Exit(run()) } func run() int { // a subcommand: cli.Parse refuses positional arguments subs := map[string]func([]string, io.Writer, io.Writer) int{ - "config": configtui.Main, - "spotify-art": spotify.ArtMain, - "spotify-login": spotify.LoginMain, + "config": configtui.Main, + "spotify-art": spotify.ArtMain, + "spotify-login": spotify.LoginMain, + "spotify-refresh": spotify.RefreshMain, + "spotify-logout": spotify.LogoutMain, } if len(os.Args) > 1 { if sub, ok := subs[os.Args[1]]; ok { From 575a965d7ec4b6718b8122ccdbb8643bab724807 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Tue, 13 Oct 2026 09:38:32 +0300 Subject: [PATCH 04/22] fix(installer): spotify-logout takes the refresh lock Signed-off-by: NovusEdge --- installer/internal/spotify/tokens.go | 26 +++++++++++- installer/internal/spotify/tokens_test.go | 52 +++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/installer/internal/spotify/tokens.go b/installer/internal/spotify/tokens.go index 0bdebd6d..89048ce1 100644 --- a/installer/internal/spotify/tokens.go +++ b/installer/internal/spotify/tokens.go @@ -152,7 +152,8 @@ func (a Auth) post(ctx context.Context, form url.Values) (tokenResponse, error) var ErrInvalidGrant = errors.New("Spotify refused the saved login; connect again") const ( - lockWait = 10 * time.Second + // Longer than post's 10s timeout, so a waiter behind a slow refresh sees its token land. + lockWait = 15 * time.Second // A refresh takes seconds; a lock this old was left by a killed run. lockStale = 30 * time.Second ) @@ -164,8 +165,14 @@ func lockFile(path string) (func(), error) { for { f, err := os.OpenFile(lock, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) if err == nil { + st, _ := f.Stat() f.Close() - return func() { os.Remove(lock) }, nil + return func() { + // a run that took this lock as stale may own the path now + if cur, e := os.Stat(lock); e == nil && st != nil && os.SameFile(st, cur) { + os.Remove(lock) + } + }, nil } if !errors.Is(err, fs.ErrExist) { return nil, err @@ -260,9 +267,24 @@ func LogoutMain(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, "usage: glowup-installer spotify-logout --file FILE") return 2 } + // The lock keeps a refresh that is mid-request from writing tokens back after the remove. + unlock, err := lockFile(*file) + if errors.Is(err, fs.ErrNotExist) { + return 0 + } + if err != nil { + fmt.Fprintln(stderr, "glowup-installer spotify-logout:", err) + return 1 + } + defer unlock() if err := os.Remove(*file); err != nil && !errors.Is(err, fs.ErrNotExist) { fmt.Fprintln(stderr, "glowup-installer spotify-logout:", err) return 1 } + // a write killed between CreateTemp and Rename leaves its tokens in a temp file + orphans, _ := filepath.Glob(filepath.Join(filepath.Dir(*file), ".spotify-*.json")) + for _, o := range orphans { + os.Remove(o) + } return 0 } diff --git a/installer/internal/spotify/tokens_test.go b/installer/internal/spotify/tokens_test.go index dcb0bbe5..8c840973 100644 --- a/installer/internal/spotify/tokens_test.go +++ b/installer/internal/spotify/tokens_test.go @@ -139,6 +139,58 @@ func TestRefreshMainExitCodes(t *testing.T) { } } +func TestLogoutWaitsForARunningRefresh(t *testing.T) { + entered, release := make(chan struct{}), make(chan struct{}) + ts := &tokenServer{reply: func(url.Values) (int, string) { + close(entered) + <-release + return 200, `{"access_token":"new-at","expires_in":3600}` + }} + p := seed(t, 1) + a := authFor(ts, t) + refreshed := make(chan error, 1) + go func() { refreshed <- Refresh(context.Background(), a, p, 1) }() + <-entered + loggedOut := make(chan int, 1) + go func() { loggedOut <- LogoutMain([]string{"--file", p}, io.Discard, io.Discard) }() + select { + case <-loggedOut: + t.Fatal("logout did not wait for the refresh") + case <-time.After(200 * time.Millisecond): + } + close(release) + if err := <-refreshed; err != nil { + t.Fatal(err) + } + if code := <-loggedOut; code != 0 { + t.Fatalf("exit %d", code) + } + if _, err := os.Stat(p); !os.IsNotExist(err) { + t.Fatal("the refresh wrote tokens after logout") + } +} + +func TestLogoutRemovesOrphanedTempFiles(t *testing.T) { + p := seed(t, 1) + orphan := filepath.Join(filepath.Dir(p), ".spotify-123.json") + if err := os.WriteFile(orphan, []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + if code := LogoutMain([]string{"--file", p}, io.Discard, io.Discard); code != 0 { + t.Fatalf("exit %d", code) + } + if _, err := os.Stat(orphan); !os.IsNotExist(err) { + t.Fatal("an orphaned temp file survived logout") + } +} + +func TestLogoutWithoutADirectory(t *testing.T) { + p := filepath.Join(t.TempDir(), "nope", "spotify.json") + if code := LogoutMain([]string{"--file", p}, io.Discard, io.Discard); code != 0 { + t.Fatalf("exit %d", code) + } +} + func TestLogout(t *testing.T) { p := seed(t, 1) if code := LogoutMain([]string{"--file", p}, io.Discard, io.Discard); code != 0 { From e70d1c9409f85982e4b58972569f832fe49e0832 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Tue, 13 Oct 2026 09:44:56 +0300 Subject: [PATCH 05/22] feat(wiring): Host.fetch with a timeout and Host.spawn by lines Signed-off-by: NovusEdge --- hooks/host.ts | 7 +++++++ hooks/register.tsx | 25 +++++++++++++++++++++++++ test/kit.test.ts | 17 +++++++++++++++++ test/kit.ts | 24 +++++++++++++++++++++--- 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/hooks/host.ts b/hooks/host.ts index b4e40b73..c1185884 100644 --- a/hooks/host.ts +++ b/hooks/host.ts @@ -1,6 +1,9 @@ // The engine as the other modules see it. The loader refuses $ passed across an // import, so register.tsx builds this from $ in hostOf. export type RunResult = { exitCode: number; stdout: string; stderr: string } +export type FetchInit = { method?: string; headers?: Record; body?: string; timeoutMs: number } +export type FetchResult = { status: number; headers: Record; text: string } +export type Spawned = { done: Promise; kill(): void } export type Host = { // env is set over the engine's own environment; timeoutMs defaults to the engine's 30 s run(argv: readonly string[], env?: Record, timeoutMs?: number): Promise @@ -13,6 +16,10 @@ export type Host = { // size is in bytes, for a file listFiles(path: string): Promise<{ name: string; size: number }[]> fetchText(url: string): Promise<{ ok: boolean; status: number; text: string }> + // rejects on a network error and once timeoutMs passes: the engine's fetch has no timeout of its own + fetch(url: string, init: FetchInit): Promise + // starts argv and hands its output over a line at a time; done is the exit code, null when killed or never started + spawn(argv: readonly string[], onLine: (stream: 'stdout' | 'stderr', line: string) => void): Spawned storeGet(key: string): Promise storeSet(key: string, value: unknown): Promise storeDelete(key: string): Promise diff --git a/hooks/register.tsx b/hooks/register.tsx index 4cf4b55e..0d9ea3c0 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -193,6 +193,31 @@ function hostOf($: Engine): Host { $.http.fetch(url).then(r => { timer.cancel(); resolve({ ok: r.ok, status: r.status, text: r.text }) }, err => { timer.cancel(); reject(err) }) }), + fetch: (url, init) => new Promise((resolve, reject) => { + const timer = $.clock.after(init.timeoutMs, () => reject(new Error(`timed out after ${init.timeoutMs / 1000} s`))) + $.http.fetch(url, { method: init.method ?? 'GET', ...(init.headers && { headers: init.headers }), ...(init.body !== undefined && { body: init.body }) }) + .then(r => { timer.cancel(); resolve({ status: r.status, headers: r.headers, text: r.text }) }, err => { timer.cancel(); reject(err) }) + }), + spawn: (argv, onLine) => { + const child = $.process.spawn({ argv }) + let killed = false + const done = (async () => { + // a piece of output can end mid-line, or hold several lines + const held = { stdout: '', stderr: '' } + try { + for await (const { stream, text } of child) { + const lines = (held[stream] + text).split('\n') + held[stream] = lines.pop()! + for (const l of lines) onLine(stream, l.replace(/\r$/, '')) + // leaving the loop is what kills the child + if (killed) return null + } + for (const s of ['stdout', 'stderr'] as const) if (held[s]) onLine(s, held[s]) + return killed ? null : (await child.result).code + } catch { return null } + })() + return { done, kill: () => { killed = true; void child.return({ code: null, signal: null }).catch(() => {}) } } + }, storeGet: key => $.store.get(key), storeSet: (key, value) => $.store.set(key, value), storeDelete: key => $.store.delete(key), diff --git a/test/kit.test.ts b/test/kit.test.ts index d7d1edac..fedc3315 100644 --- a/test/kit.test.ts +++ b/test/kit.test.ts @@ -7,3 +7,20 @@ test('fakeHost lists a folder that only holds folders, and stats files', async ( expect(await host.stat('/r/a/owner')).toEqual({ mtimeMs: 5 }) expect(await host.stat('/r/missing')).toBeUndefined() }) + +test('fakeHost answers fetch from its handler and records each request; spawn streams lines and can be killed', async () => { + const { host, requests, spawned, killed } = fakeHost({ + http: req => ({ status: req.url.endsWith('/a') ? 200 : 404, headers: {}, text: 'x' }), + spawns: async (_argv, emit) => { emit('stdout', 'first'); return new Promise(() => {}) }, + }) + expect(await host.fetch('https://h/a', { method: 'PUT', headers: { A: '1' }, timeoutMs: 5000 })).toEqual({ status: 200, headers: {}, text: 'x' }) + expect(requests).toEqual([{ method: 'PUT', url: 'https://h/a', headers: { A: '1' } }]) + const lines: string[] = [] + const child = host.spawn(['gi', 'spotify-login'], (s, l) => lines.push(`${s}:${l}`)) + expect(lines).toEqual(['stdout:first']) + child.kill() + expect(await child.done).toBeNull() + expect(spawned).toEqual(['gi spotify-login']) + expect(killed).toEqual(['gi spotify-login']) + expect(await fakeHost().host.fetch('https://h', { timeoutMs: 1 }).catch((e: Error) => e.message)).toBe('offline') +}) diff --git a/test/kit.ts b/test/kit.ts index e2c720d9..938a1f49 100644 --- a/test/kit.ts +++ b/test/kit.ts @@ -1,6 +1,6 @@ import { mock, test as baseTest, type Engine } from 'claude-code/testing' import type { On } from 'claude-code' -import type { Host } from '../hooks/host.ts' +import type { FetchResult, Host } from '../hooks/host.ts' import type { PetLines } from '../hooks/lines.ts' // Every line a moment can draw, any flavour, with its slots filled as the bubble would. @@ -17,14 +17,19 @@ export const test = ((name: string, ...rest: unknown[]) => { export const runGlowup = ($: Engine, args = '', columns = 120, isFullscreen = false) => $.command.run({ command: 'glowup', args, origin: { kind: 'composer' }, presentation: { isFullscreen, columns } }) +export type FakeRequest = { method: string; url: string; headers: Record; body?: string } + // An in-memory Host: files is a path -> text map, runs answers argv strings. -export function fakeHost(opts: { files?: Record; runs?: Record; fetches?: Record; projectStatusLine?: boolean; mtimes?: Record } = {}) { +export function fakeHost(opts: { files?: Record; runs?: Record; fetches?: Record; projectStatusLine?: boolean; mtimes?: Record; http?: (req: FakeRequest) => FetchResult | Promise; spawns?: (argv: string[], emit: (stream: 'stdout' | 'stderr', line: string) => void) => Promise } = {}) { const files = { ...(opts.files ?? {}) } const mtimes = { ...(opts.mtimes ?? {}) } const store: Record = {} const ran: string[] = [] const envs: (Record | undefined)[] = [] const timeouts: (number | undefined)[] = [] + const requests: FakeRequest[] = [] + const spawned: string[] = [] + const killed: string[] = [] const host: Host = { run: async (argv, env, timeoutMs) => { ran.push(argv.join(' ')) @@ -41,6 +46,19 @@ export function fakeHost(opts: { files?: Record; runs?: Record [...new Set(Object.keys(files).filter(f => f.startsWith(p + '/')).map(f => f.slice(p.length + 1).split('/')[0]!))], listFiles: async p => Object.keys(files).filter(f => f.startsWith(p + '/')).map(f => ({ name: f.slice(p.length + 1), size: files[f]!.length })).filter(e => !e.name.includes('/')), fetchText: async url => url in (opts.fetches ?? {}) ? { ok: true, status: 200, text: opts.fetches![url]! } : { ok: false, status: 404, text: '' }, + fetch: async (url, init) => { + const req: FakeRequest = { method: init.method ?? 'GET', url, headers: init.headers ?? {}, ...(init.body !== undefined && { body: init.body }) } + requests.push(req) + if (!opts.http) throw new Error('offline') + return opts.http(req) + }, + spawn: (argv, onLine) => { + const key = argv.join(' ') + spawned.push(key) + let kill = () => {} + const stopped = new Promise(r => { kill = () => { killed.push(key); r(null) } }) + return { done: Promise.race([opts.spawns ? opts.spawns([...argv], onLine) : Promise.resolve(127), stopped]), kill: () => kill() } + }, storeGet: async k => store[k], storeSet: async (k, v) => { store[k] = v }, storeDelete: async k => { delete store[k] }, @@ -49,7 +67,7 @@ export function fakeHost(opts: { files?: Record; runs?: Record Date: Tue, 13 Oct 2026 09:49:24 +0300 Subject: [PATCH 06/22] fix(wiring): spawn reports onLine errors and stops at kill Signed-off-by: NovusEdge --- hooks/register.tsx | 13 +++++++++---- test/kit.test.ts | 5 +++++ test/kit.ts | 2 +- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/hooks/register.tsx b/hooks/register.tsx index 0d9ea3c0..f3b702bc 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -204,17 +204,22 @@ function hostOf($: Engine): Host { const done = (async () => { // a piece of output can end mid-line, or hold several lines const held = { stdout: '', stderr: '' } + const line = (s: string) => s.replace(/\r$/, '') + // an onLine that throws is the caller's bug: it ends the child but must reach done + let bad: { error: unknown } | undefined try { for await (const { stream, text } of child) { const lines = (held[stream] + text).split('\n') held[stream] = lines.pop()! - for (const l of lines) onLine(stream, l.replace(/\r$/, '')) + try { for (const l of lines) onLine(stream, line(l)) } catch (error) { bad = { error }; break } // leaving the loop is what kills the child if (killed) return null } - for (const s of ['stdout', 'stderr'] as const) if (held[s]) onLine(s, held[s]) - return killed ? null : (await child.result).code - } catch { return null } + } catch { if (!bad) return null } + if (bad) throw bad.error + if (killed) return null + for (const s of ['stdout', 'stderr'] as const) if (held[s]) onLine(s, line(held[s])) + try { return (await child.result).code } catch { return null } })() return { done, kill: () => { killed = true; void child.return({ code: null, signal: null }).catch(() => {}) } } }, diff --git a/test/kit.test.ts b/test/kit.test.ts index fedc3315..9ae7101d 100644 --- a/test/kit.test.ts +++ b/test/kit.test.ts @@ -24,3 +24,8 @@ test('fakeHost answers fetch from its handler and records each request; spawn st expect(killed).toEqual(['gi spotify-login']) expect(await fakeHost().host.fetch('https://h', { timeoutMs: 1 }).catch((e: Error) => e.message)).toBe('offline') }) + +test('fakeHost spawn resolves null when the child could not start', async () => { + const { host } = fakeHost({ spawns: async () => { throw new Error('ENOENT') } }) + expect(await host.spawn(['nope'], () => {}).done).toBeNull() +}) diff --git a/test/kit.ts b/test/kit.ts index 938a1f49..65496a8c 100644 --- a/test/kit.ts +++ b/test/kit.ts @@ -57,7 +57,7 @@ export function fakeHost(opts: { files?: Record; runs?: Record {} const stopped = new Promise(r => { kill = () => { killed.push(key); r(null) } }) - return { done: Promise.race([opts.spawns ? opts.spawns([...argv], onLine) : Promise.resolve(127), stopped]), kill: () => kill() } + return { done: Promise.race([opts.spawns ? opts.spawns([...argv], onLine).catch(() => null) : Promise.resolve(127), stopped]), kill: () => kill() } }, storeGet: async k => store[k], storeSet: async (k, v) => { store[k] = v }, From e005ecb893b460d1b59d2c8f080488e9871cf84b Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Tue, 13 Oct 2026 09:56:22 +0300 Subject: [PATCH 07/22] feat(wiring): Spotify token file, Web API requests and parsers Signed-off-by: NovusEdge --- hooks/music/parse.ts | 13 ++++- hooks/music/tokens.ts | 67 +++++++++++++++++++++ hooks/music/webapi.ts | 120 ++++++++++++++++++++++++++++++++++++++ test/music-parse.test.ts | 9 +++ test/music-tokens.test.ts | 84 ++++++++++++++++++++++++++ test/music-webapi.test.ts | 88 ++++++++++++++++++++++++++++ 6 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 hooks/music/tokens.ts create mode 100644 hooks/music/webapi.ts create mode 100644 test/music-tokens.test.ts create mode 100644 test/music-webapi.test.ts diff --git a/hooks/music/parse.ts b/hooks/music/parse.ts index 5b05777b..fe03c94e 100644 --- a/hooks/music/parse.ts +++ b/hooks/music/parse.ts @@ -1,8 +1,15 @@ // JSX-free and engine-free: parsers only, so they test on recorded output. export type Repeat = 'off' | 'context' | 'track' export type Track = { id: string; title: string; artist: string; album: string; durationMs: number; artUrl?: string; url?: string; uri?: string } -// shuffle, repeat and volume (0..1) are absent when the player did not report them, and their controls hide -export type OpenMusic = { status: 'open'; track?: Track; playing: boolean; ad: boolean; progressMs: number; readAt: number; shuffle?: boolean; repeat?: Repeat; volume?: number } +export type Device = { id: string; name: string; type: string; isThisComputer: boolean } +export type QueueItem = { uri: string; title: string; artist: string; durationMs: number } +// shuffle, repeat and volume (0..1) are absent when the player did not report them, and their controls hide. +// source, device, liked and upNext are set only while Spotify is connected: source is api while the Web API +// answered within two polls, local while glowup falls back to the app on this computer. +export type OpenMusic = { + status: 'open'; track?: Track; playing: boolean; ad: boolean; progressMs: number; readAt: number; shuffle?: boolean; repeat?: Repeat; volume?: number + source?: 'api' | 'local'; device?: Device; liked?: boolean; upNext?: QueueItem +} export type Music = { status: 'closed' } | { status: 'unsupported'; hint: string } | OpenMusic export type MusicCmd = | { cmd: 'playpause' | 'next' | 'previous' | 'raise' } @@ -10,6 +17,7 @@ export type MusicCmd = | { cmd: 'shuffle'; on: boolean } | { cmd: 'repeat'; mode: Repeat } | { cmd: 'volume'; level: number } + | { cmd: 'like'; on: boolean } export type Extras = Pick const isAd = (id: string) => id.startsWith('/com/spotify/ad/') || id.startsWith('spotify:ad:') @@ -146,5 +154,6 @@ export function sameMusic(a: Music | undefined, b: Music, now: number): boolean if (a.status === 'closed' || b.status !== 'open') return true const o = a as OpenMusic return o.playing === b.playing && o.ad === b.ad && o.shuffle === b.shuffle && o.repeat === b.repeat && o.volume === b.volume + && o.source === b.source && o.liked === b.liked && o.upNext?.uri === b.upNext?.uri && JSON.stringify(o.device) === JSON.stringify(b.device) && JSON.stringify(o.track) === JSON.stringify(b.track) && Math.abs(progressAt(o, now) - progressAt(b, now)) <= SEEK_MS } diff --git a/hooks/music/tokens.ts b/hooks/music/tokens.ts new file mode 100644 index 00000000..485548ee --- /dev/null +++ b/hooks/music/tokens.ts @@ -0,0 +1,67 @@ +// The Spotify token file. Only glowup-installer writes it (spotify-login, -refresh and -logout), so its mode +// stays 0600; this side reads it and asks the helper for a refresh. +import type { Host } from '../host.ts' +import { needsRefresh } from './webapi.ts' + +export const TOKENS_FORMAT = 1 +export type Tokens = { format: number; clientId: string; accessToken: string; refreshToken: string; expiresAt: number; scope: string; connectedAt: number; gen: number } +export type TokenRead = { ok: true; tokens: Tokens } | { ok: false; why: 'missing' | 'newer' | 'damaged' } +export const tokenFile = (configDir: string) => `${configDir}/glowup/spotify.json` +// spotify-login polls this for an address pasted into the dialog +export const pasteFile = (configDir: string) => `${configDir}/glowup/spotify-paste` + +export async function readTokens(host: Host, path: string): Promise { + let text: string + try { text = await host.readFile(path) } catch { return { ok: false, why: 'missing' } } + let o: Record + try { o = JSON.parse(text) } catch { return { ok: false, why: 'damaged' } } + if (!o || typeof o !== 'object') return { ok: false, why: 'damaged' } + // The helper overwrites whatever file is there, so this check is the only thing keeping a newer glowup's tokens from being refreshed into the old format. + if (typeof o.format === 'number' && o.format > TOKENS_FORMAT) return { ok: false, why: 'newer' } + const s = (v: unknown): v is string => typeof v === 'string' && v !== '' + const n = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) + if (o.format !== TOKENS_FORMAT || !s(o.clientId) || !s(o.accessToken) || !s(o.refreshToken) || !n(o.expiresAt)) return { ok: false, why: 'damaged' } + return { ok: true, tokens: { format: TOKENS_FORMAT, clientId: o.clientId, accessToken: o.accessToken, refreshToken: o.refreshToken, expiresAt: o.expiresAt, scope: typeof o.scope === 'string' ? o.scope : '', connectedAt: n(o.connectedAt) ? o.connectedAt : 0, gen: n(o.gen) ? o.gen : 0 } } +} + +// Runs `spotify-refresh --if-gen gen` and resolves its exit code: 0 refreshed (by this run or a racing one), 3 refused. +export type Refresher = (gen: number) => Promise +export type Access = { token: string } | { error: 'none' | 'newer' | 'damaged' | 'reconnect' | 'failed' } + +export class TokenKeeper { + private tokens: Tokens | undefined + private state: 'ok' | 'none' | 'newer' | 'damaged' | 'reconnect' = 'none' + private flight: Promise | undefined + constructor(private host: Host, readonly path: string, private refresher: Refresher) {} + + async load(): Promise { + const r = await readTokens(this.host, this.path) + this.tokens = r.ok ? r.tokens : undefined + this.state = r.ok ? 'ok' : r.why === 'missing' ? 'none' : r.why + return r + } + status() { return this.state } + // Spotify answered 401 to a token that was just refreshed + lost() { this.state = 'reconnect' } + + // A token to send. It refreshes first in the token's last minute, or when rejected is the token Spotify just answered 401 to. + async access(now: number, rejected?: string): Promise { + const t = this.tokens + if (this.state !== 'ok' || !t) return { error: this.state === 'ok' ? 'none' : this.state } + const stale = rejected === undefined ? needsRefresh(t.expiresAt, now) : rejected === t.accessToken + if (!stale) return { token: t.accessToken } + // one refresh at a time in this session: callers that arrive meanwhile wait for the same one + this.flight ??= this.refresh(t, now, rejected !== undefined).finally(() => { this.flight = undefined }) + return this.flight + } + + private async refresh(t: Tokens, now: number, rejected: boolean): Promise { + const code = await this.refresher(t.gen).catch(() => 1) + // The helper is the only writer, so the file is the truth, even after exit 3: a racing session may have refreshed first. + const again = await readTokens(this.host, this.path) + if (again.ok && again.tokens.gen !== t.gen) { this.tokens = again.tokens; return { token: again.tokens.accessToken } } + if (code === 3) { this.state = 'reconnect'; return { error: 'reconnect' } } + // offline, or the helper failed: the old token while it lasts, unless Spotify already refused it + return !rejected && t.expiresAt > now ? { token: t.accessToken } : { error: 'failed' } + } +} diff --git a/hooks/music/webapi.ts b/hooks/music/webapi.ts new file mode 100644 index 00000000..d8073d0f --- /dev/null +++ b/hooks/music/webapi.ts @@ -0,0 +1,120 @@ +// JSX-free and engine-free: Spotify Web API requests as data, and its answers parsed into the shared Music shapes. +import type { Device, MusicCmd, OpenMusic, QueueItem, Repeat, Track } from './parse.ts' + +export const API = 'https://api.spotify.com/v1' +export type ApiRequest = { method: 'GET' | 'PUT' | 'POST' | 'DELETE'; url: string } + +// Without additional_types=episode, a podcast episode comes back as item: null. +export const playerRequest = (): ApiRequest => ({ method: 'GET', url: `${API}/me/player?additional_types=episode` }) +export const queueRequest = (): ApiRequest => ({ method: 'GET', url: `${API}/me/player/queue?additional_types=episode` }) +export const meRequest = (): ApiRequest => ({ method: 'GET', url: `${API}/me` }) +// /me/tracks was removed in February 2026; /me/library takes spotify: URIs +export const likedRequest = (uri: string): ApiRequest => ({ method: 'GET', url: `${API}/me/library/contains?uris=${encodeURIComponent(uri)}` }) +export const likeRequest = (uri: string, on: boolean): ApiRequest => ({ method: on ? 'PUT' : 'DELETE', url: `${API}/me/library?uris=${encodeURIComponent(uri)}` }) + +const unit = (v: number) => Math.min(1, Math.max(0, v)) + +// The write a transport command makes; playing picks pause or play. like goes through likeRequest, and raise is local only. +export function commandRequest(c: MusicCmd, playing: boolean): ApiRequest | undefined { + switch (c.cmd) { + case 'playpause': return { method: 'PUT', url: `${API}/me/player/${playing ? 'pause' : 'play'}` } + case 'next': return { method: 'POST', url: `${API}/me/player/next` } + case 'previous': return { method: 'POST', url: `${API}/me/player/previous` } + case 'seek': return { method: 'PUT', url: `${API}/me/player/seek?position_ms=${Math.max(0, Math.round(c.ms))}` } + case 'shuffle': return { method: 'PUT', url: `${API}/me/player/shuffle?state=${c.on}` } + case 'repeat': return { method: 'PUT', url: `${API}/me/player/repeat?state=${c.mode}` } + case 'volume': return { method: 'PUT', url: `${API}/me/player/volume?volume_percent=${Math.round(unit(c.level) * 100)}` } + default: return undefined + } +} + +// A minute early, so a token never expires between the check and Spotify reading it. +export const needsRefresh = (expiresAt: number, now: number) => expiresAt - now < 60_000 + +type Obj = Record +const obj = (v: unknown): Obj | undefined => (v && typeof v === 'object' && !Array.isArray(v) ? v as Obj : undefined) +const str = (v: unknown) => (typeof v === 'string' ? v : '') +const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0) +const json = (text: string): unknown => { try { return JSON.parse(text) } catch { return undefined } } +const REPEATS: readonly unknown[] = ['off', 'context', 'track'] + +// The narrowest picture with a known width: the cover helper shrinks it to 20 px at most. +function smallest(images: unknown): string | undefined { + const all = Array.isArray(images) ? images.map(obj).filter((i): i is Obj => !!i && typeof i.url === 'string') : [] + const sized = all.filter(i => num(i.width) > 0).sort((a, b) => num(a.width) - num(b.width)) + return str((sized[0] ?? all[0])?.url) || undefined +} + +// A track or an episode; the API names both by spotify: URI, which is also the id here. +function trackOf(v: unknown): Track | undefined { + const it = obj(v), uri = str(it?.uri) + if (!it || !uri) return undefined + const episode = it.type === 'episode', album = obj(it.album), show = obj(it.show) + const artist = episode ? str(show?.name) : (Array.isArray(it.artists) ? it.artists : []).map(a => str(obj(a)?.name)).filter(Boolean).join(', ') + const artUrl = smallest(episode ? it.images ?? show?.images : album?.images) + const url = str(obj(it.external_urls)?.spotify) + return { id: uri, title: str(it.name), artist, album: episode ? '' : str(album?.name), durationMs: Math.max(0, Math.round(num(it.duration_ms))), uri, ...(artUrl && { artUrl }), ...(url && { url }) } +} + +// GET /me/player's 200 body. readAt is when the answer arrived, not Spotify's timestamp, +// so another device's clock cannot move the bar. +export function parsePlayer(text: string, readAt: number): OpenMusic | undefined { + const o = obj(json(text)) + if (!o) return undefined + const d = obj(o.device) + const device: Device | undefined = d && str(d.name) ? { id: str(d.id), name: str(d.name), type: str(d.type), isThisComputer: false } : undefined + const track = trackOf(o.item) + const volume = d && typeof d.volume_percent === 'number' && d.supports_volume !== false ? unit(d.volume_percent / 100) : undefined + return { + status: 'open', playing: o.is_playing === true, ad: o.currently_playing_type === 'ad', progressMs: Math.max(0, Math.round(num(o.progress_ms))), readAt, source: 'api', + ...(track && { track }), + ...(typeof o.shuffle_state === 'boolean' && { shuffle: o.shuffle_state }), + ...(REPEATS.includes(o.repeat_state) && { repeat: o.repeat_state as Repeat }), + ...(volume !== undefined && { volume }), + ...(device && { device }), + } +} + +export function parseQueue(text: string): QueueItem[] | undefined { + const q = obj(json(text))?.queue + if (!Array.isArray(q)) return undefined + return q.map(trackOf).filter((t): t is Track => !!t).map(t => ({ uri: t.uri!, title: t.title, artist: t.artist, durationMs: t.durationMs })) +} + +export function parseLiked(text: string): boolean | undefined { + const a = json(text) + return Array.isArray(a) && typeof a[0] === 'boolean' ? a[0] : undefined +} + +export function parseMe(text: string): string | undefined { + const o = obj(json(text)) + return str(o?.display_name) || str(o?.id) || undefined +} + +export type ApiError = + // a network error, or no answer within the 5 s timeout + | { kind: 'offline' } + // the same endpoint still has a request out + | { kind: 'busy' } + // a 429: nothing goes out before untilMs + | { kind: 'rate'; untilMs: number } + // a 401 after a refresh, or the refresh token refused: the person must connect again + | { kind: 'reconnect' } + | { kind: 'premium' } + | { kind: 'noDevice' } + | { kind: 'refused'; reason: string } + | { kind: 'server'; status: number } + +// Spotify's error.reason tells PREMIUM_REQUIRED and NO_ACTIVE_DEVICE from other 403s and 404s. +export function apiError(status: number, text: string, headers: Record, now: number): ApiError { + if (status === 429) { + const s = Number(headers['retry-after']) + return { kind: 'rate', untilMs: now + (Number.isFinite(s) && s > 0 ? s : 5) * 1000 } + } + if (status >= 500) return { kind: 'server', status } + if (status === 401) return { kind: 'reconnect' } + const reason = str(obj(obj(json(text))?.error)?.reason) + if (status === 403 && reason === 'PREMIUM_REQUIRED') return { kind: 'premium' } + if (status === 404 && reason === 'NO_ACTIVE_DEVICE') return { kind: 'noDevice' } + return { kind: 'refused', reason: reason || String(status) } +} diff --git a/test/music-parse.test.ts b/test/music-parse.test.ts index d95eee0a..fc63adca 100644 --- a/test/music-parse.test.ts +++ b/test/music-parse.test.ts @@ -140,3 +140,12 @@ test('sameMusic notices shuffle, repeat and volume', () => { expect(sameMusic(a, { ...a, repeat: 'track' }, 0)).toBe(false) expect(sameMusic(a, { ...a, volume: 0.6 }, 0)).toBe(false) }) + +test('sameMusic sees the connected fields change', () => { + const a: OpenMusic = { status: 'open', playing: true, ad: false, progressMs: 0, readAt: 0, track: { id: 't', title: 'T', artist: 'A', album: '', durationMs: 9000, uri: 'spotify:track:t' }, source: 'api' } + expect(sameMusic(a, { ...a }, 0)).toBe(true) + expect(sameMusic(a, { ...a, source: 'local' }, 0)).toBe(false) + expect(sameMusic(a, { ...a, liked: true }, 0)).toBe(false) + expect(sameMusic(a, { ...a, device: { id: 'd', name: 'Pixel 9', type: 'Smartphone', isThisComputer: false } }, 0)).toBe(false) + expect(sameMusic(a, { ...a, upNext: { uri: 'spotify:track:n', title: 'N', artist: 'B', durationMs: 1 } }, 0)).toBe(false) +}) diff --git a/test/music-tokens.test.ts b/test/music-tokens.test.ts new file mode 100644 index 00000000..a03e0399 --- /dev/null +++ b/test/music-tokens.test.ts @@ -0,0 +1,84 @@ +import { expect } from 'claude-code/testing' +import { fakeHost, test } from './kit.ts' +import { readTokens, TokenKeeper, tokenFile } from '../hooks/music/tokens.ts' + +const PATH = tokenFile('/home/u/.claude') +const file = (over: Record = {}) => JSON.stringify({ format: 1, clientId: 'c'.repeat(32), accessToken: 'at-1', refreshToken: 'rt-1', expiresAt: 3_600_000, scope: 's', connectedAt: 0, gen: 1, ...over }) + +test('the token file reads, and a newer or damaged one is refused', async () => { + expect(PATH).toBe('/home/u/.claude/glowup/spotify.json') + expect(await readTokens(fakeHost({ files: { [PATH]: file() } }).host, PATH)).toMatchObject({ ok: true, tokens: { accessToken: 'at-1', gen: 1 } }) + expect(await readTokens(fakeHost().host, PATH)).toEqual({ ok: false, why: 'missing' }) + expect(await readTokens(fakeHost({ files: { [PATH]: file({ format: 2 }) } }).host, PATH)).toEqual({ ok: false, why: 'newer' }) + expect(await readTokens(fakeHost({ files: { [PATH]: '{' } }).host, PATH)).toEqual({ ok: false, why: 'damaged' }) + expect(await readTokens(fakeHost({ files: { [PATH]: file({ accessToken: 7 }) } }).host, PATH)).toEqual({ ok: false, why: 'damaged' }) +}) + +test('a token far from expiry is used as is; in its last minute it refreshes through the helper', async () => { + const { host, files } = fakeHost({ files: { [PATH]: file() } }) + const gens: number[] = [] + const keeper = new TokenKeeper(host, PATH, async gen => { gens.push(gen); files[PATH] = file({ accessToken: 'at-2', expiresAt: 7_200_000, gen: gen + 1 }); return 0 }) + await keeper.load() + expect(await keeper.access(0)).toEqual({ token: 'at-1' }) + expect(await keeper.access(3_600_000 - 59_000)).toEqual({ token: 'at-2' }) + expect(gens).toEqual([1]) +}) + +test('two callers share one refresh', async () => { + const { host, files } = fakeHost({ files: { [PATH]: file({ expiresAt: 0 }) } }) + let calls = 0, release = () => {} + const keeper = new TokenKeeper(host, PATH, gen => { + calls++ + return new Promise(r => { release = () => { files[PATH] = file({ accessToken: 'at-2', gen: gen + 1 }); r(0) } }) + }) + await keeper.load() + const a = keeper.access(1000), b = keeper.access(1000) + release() + expect(await Promise.all([a, b])).toEqual([{ token: 'at-2' }, { token: 'at-2' }]) + expect(calls).toBe(1) +}) + +test('a 401 refreshes an unexpired token once; a caller with the old token gets the new one', async () => { + const { host, files } = fakeHost({ files: { [PATH]: file() } }) + const gens: number[] = [] + const keeper = new TokenKeeper(host, PATH, async gen => { gens.push(gen); files[PATH] = file({ accessToken: 'at-2', gen: gen + 1 }); return 0 }) + await keeper.load() + expect(await keeper.access(0, 'at-1')).toEqual({ token: 'at-2' }) + expect(await keeper.access(0, 'at-1')).toEqual({ token: 'at-2' }) + expect(gens).toEqual([1]) +}) + +test('exit 3 means Reconnect, unless another session already refreshed', async () => { + const one = fakeHost({ files: { [PATH]: file({ expiresAt: 0 }) } }) + const lost = new TokenKeeper(one.host, PATH, async () => 3) + await lost.load() + expect(await lost.access(1000)).toEqual({ error: 'reconnect' }) + expect(lost.status()).toBe('reconnect') + const two = fakeHost({ files: { [PATH]: file({ expiresAt: 0 }) } }) + const raced = new TokenKeeper(two.host, PATH, async gen => { two.files[PATH] = file({ accessToken: 'at-9', gen: gen + 1 }); return 3 }) + await raced.load() + expect(await raced.access(1000)).toEqual({ token: 'at-9' }) + expect(raced.status()).toBe('ok') +}) + +test('a failed refresh keeps the old token until it expires, and is not Reconnect', async () => { + const { host } = fakeHost({ files: { [PATH]: file({ expiresAt: 30_000 }) } }) + const keeper = new TokenKeeper(host, PATH, async () => 1) + await keeper.load() + expect(await keeper.access(0)).toEqual({ token: 'at-1' }) + expect(await keeper.access(40_000)).toEqual({ error: 'failed' }) + expect(await keeper.access(0, 'at-1')).toEqual({ error: 'failed' }) + expect(keeper.status()).toBe('ok') +}) + +test('a newer token file is refused and never refreshed', async () => { + const { host } = fakeHost({ files: { [PATH]: file({ format: 2 }) } }) + let calls = 0 + const keeper = new TokenKeeper(host, PATH, async () => { calls++; return 0 }) + expect(await keeper.load()).toEqual({ ok: false, why: 'newer' }) + expect(await keeper.access(0)).toEqual({ error: 'newer' }) + // expired and rejected tokens are the two reasons to refresh; a newer file must survive both + expect(await keeper.access(10_000_000)).toEqual({ error: 'newer' }) + expect(await keeper.access(0, 'at-1')).toEqual({ error: 'newer' }) + expect(calls).toBe(0) +}) diff --git a/test/music-webapi.test.ts b/test/music-webapi.test.ts new file mode 100644 index 00000000..9819664d --- /dev/null +++ b/test/music-webapi.test.ts @@ -0,0 +1,88 @@ +import { test, expect } from 'claude-code/testing' +import { apiError, commandRequest, likedRequest, likeRequest, meRequest, needsRefresh, parseLiked, parseMe, parsePlayer, parseQueue, playerRequest, queueRequest } from '../hooks/music/webapi.ts' + +// Recorded shapes of the Web API's answers, trimmed to the fields glowup reads. +const ITEM = { + type: 'track', id: 'tid', uri: 'spotify:track:tid', name: 'BACK 2 THE BASICS', duration_ms: 174000, + artists: [{ name: 'Double A-Ron' }, { name: 'Guest' }], + album: { name: 'Commercial Break 2', images: [ + { url: 'https://i.scdn.co/image/ab67616d0000b273aa', width: 640, height: 640 }, + { url: 'https://i.scdn.co/image/ab67616d00004851aa', width: 64, height: 64 }, + { url: 'https://i.scdn.co/image/ab67616d00001e02aa', width: 300, height: 300 }, + ] }, + external_urls: { spotify: 'https://open.spotify.com/track/tid' }, +} +const EPISODE = { type: 'episode', uri: 'spotify:episode:eid', name: 'Ep 12', duration_ms: 3_600_000, show: { name: 'The Show' }, images: [{ url: 'https://i.scdn.co/image/ep64', width: 64, height: 64 }], external_urls: { spotify: 'https://open.spotify.com/episode/eid' } } +const PHONE = { id: 'dev-phone', is_active: true, name: 'Pixel 9', type: 'Smartphone', volume_percent: 40, supports_volume: true } +const DESK = { id: 'dev-desk', is_active: true, name: 'Desktop', type: 'Computer', volume_percent: 100, supports_volume: true } +const player = (over: object) => JSON.stringify({ device: PHONE, shuffle_state: true, repeat_state: 'context', timestamp: 1, progress_ms: 61000, is_playing: true, currently_playing_type: 'track', item: ITEM, ...over }) + +test('a track on a phone parses to the shared shape, with the smallest cover', () => { + expect(parsePlayer(player({}), 500)).toEqual({ + status: 'open', playing: true, ad: false, progressMs: 61000, readAt: 500, source: 'api', shuffle: true, repeat: 'context', volume: 0.4, + device: { id: 'dev-phone', name: 'Pixel 9', type: 'Smartphone', isThisComputer: false }, + track: { id: 'spotify:track:tid', title: 'BACK 2 THE BASICS', artist: 'Double A-Ron, Guest', album: 'Commercial Break 2', durationMs: 174000, uri: 'spotify:track:tid', artUrl: 'https://i.scdn.co/image/ab67616d00004851aa', url: 'https://open.spotify.com/track/tid' }, + }) +}) + +test('an episode, an ad, and answers that are not a player', () => { + const ep = parsePlayer(player({ device: DESK, is_playing: false, currently_playing_type: 'episode', item: EPISODE, repeat_state: 'off', shuffle_state: false }), 0)! + expect(ep.track).toEqual({ id: 'spotify:episode:eid', title: 'Ep 12', artist: 'The Show', album: '', durationMs: 3_600_000, uri: 'spotify:episode:eid', artUrl: 'https://i.scdn.co/image/ep64', url: 'https://open.spotify.com/episode/eid' }) + expect([ep.playing, ep.repeat, ep.shuffle, ep.volume]).toEqual([false, 'off', false, 1]) + const ad = parsePlayer(player({ currently_playing_type: 'ad', item: null }), 0)! + expect([ad.ad, ad.track]).toEqual([true, undefined]) + expect(parsePlayer('', 0)).toBeUndefined() + expect(parsePlayer('null', 0)).toBeUndefined() + expect(parsePlayer(player({ device: { ...PHONE, supports_volume: false } }), 0)!.volume).toBeUndefined() +}) + +test('the queue, the library and the account', () => { + expect(parseQueue(JSON.stringify({ currently_playing: ITEM, queue: [{ ...ITEM, uri: 'spotify:track:n', name: 'Next' }, EPISODE, null] }))).toEqual([ + { uri: 'spotify:track:n', title: 'Next', artist: 'Double A-Ron, Guest', durationMs: 174000 }, + { uri: 'spotify:episode:eid', title: 'Ep 12', artist: 'The Show', durationMs: 3_600_000 }, + ]) + expect(parseQueue('{}')).toBeUndefined() + expect([parseLiked('[true]'), parseLiked('[false]'), parseLiked('{}')]).toEqual([true, false, undefined]) + expect([parseMe('{"display_name":"Ada","id":"ada1"}'), parseMe('{"display_name":null,"id":"ada1"}'), parseMe('[]')]).toEqual(['Ada', 'ada1', undefined]) +}) + +test('the requests: episodes asked for, the library by URI, and never /me/tracks', () => { + expect(playerRequest()).toEqual({ method: 'GET', url: 'https://api.spotify.com/v1/me/player?additional_types=episode' }) + expect(queueRequest()).toEqual({ method: 'GET', url: 'https://api.spotify.com/v1/me/player/queue?additional_types=episode' }) + expect(meRequest()).toEqual({ method: 'GET', url: 'https://api.spotify.com/v1/me' }) + expect(likedRequest('spotify:track:tid')).toEqual({ method: 'GET', url: 'https://api.spotify.com/v1/me/library/contains?uris=spotify%3Atrack%3Atid' }) + expect(likeRequest('spotify:track:tid', true)).toEqual({ method: 'PUT', url: 'https://api.spotify.com/v1/me/library?uris=spotify%3Atrack%3Atid' }) + expect(likeRequest('spotify:track:tid', false).method).toBe('DELETE') + const all = [playerRequest(), queueRequest(), meRequest(), likedRequest('spotify:track:x'), likeRequest('spotify:track:x', true)] + expect(JSON.stringify(all)).not.toContain('/me/tracks') +}) + +test('a transport command is one write', () => { + const at = (c: Parameters[0], playing = true) => commandRequest(c, playing) + expect(at({ cmd: 'playpause' })).toEqual({ method: 'PUT', url: 'https://api.spotify.com/v1/me/player/pause' }) + expect(at({ cmd: 'playpause' }, false)).toEqual({ method: 'PUT', url: 'https://api.spotify.com/v1/me/player/play' }) + expect(at({ cmd: 'next' })).toEqual({ method: 'POST', url: 'https://api.spotify.com/v1/me/player/next' }) + expect(at({ cmd: 'previous' })).toEqual({ method: 'POST', url: 'https://api.spotify.com/v1/me/player/previous' }) + expect(at({ cmd: 'seek', ms: 61234.4 })!.url).toBe('https://api.spotify.com/v1/me/player/seek?position_ms=61234') + expect(at({ cmd: 'shuffle', on: false })!.url).toBe('https://api.spotify.com/v1/me/player/shuffle?state=false') + expect(at({ cmd: 'repeat', mode: 'track' })!.url).toBe('https://api.spotify.com/v1/me/player/repeat?state=track') + expect(at({ cmd: 'volume', level: 0.456 })!.url).toBe('https://api.spotify.com/v1/me/player/volume?volume_percent=46') + expect([at({ cmd: 'raise' }), at({ cmd: 'like', on: true })]).toEqual([undefined, undefined]) +}) + +test('errors by status and Spotify reason', () => { + const body = (reason: string) => JSON.stringify({ error: { status: 403, message: 'x', reason } }) + expect(apiError(429, '', { 'retry-after': '7' }, 1000)).toEqual({ kind: 'rate', untilMs: 8000 }) + expect(apiError(429, '', {}, 1000)).toEqual({ kind: 'rate', untilMs: 6000 }) + expect(apiError(503, '', {}, 0)).toEqual({ kind: 'server', status: 503 }) + expect(apiError(401, '', {}, 0)).toEqual({ kind: 'reconnect' }) + expect(apiError(403, body('PREMIUM_REQUIRED'), {}, 0)).toEqual({ kind: 'premium' }) + expect(apiError(403, body('UNKNOWN'), {}, 0)).toEqual({ kind: 'refused', reason: 'UNKNOWN' }) + expect(apiError(403, '', {}, 0)).toEqual({ kind: 'refused', reason: '403' }) + expect(apiError(404, JSON.stringify({ error: { status: 404, reason: 'NO_ACTIVE_DEVICE' } }), {}, 0)).toEqual({ kind: 'noDevice' }) +}) + +test('a token is refreshed in its last minute', () => { + expect(needsRefresh(100_000, 40_001)).toBe(true) + expect(needsRefresh(100_000, 40_000)).toBe(false) +}) From b4ac742d64c6550db519c74be0e30d708e6e1b18 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Tue, 13 Oct 2026 10:00:26 +0300 Subject: [PATCH 08/22] fix(wiring): token keeper remembers a refused file Signed-off-by: NovusEdge --- hooks/music/tokens.ts | 4 +++- test/music-tokens.test.ts | 28 +++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/hooks/music/tokens.ts b/hooks/music/tokens.ts index 485548ee..e4f5f379 100644 --- a/hooks/music/tokens.ts +++ b/hooks/music/tokens.ts @@ -16,7 +16,7 @@ export async function readTokens(host: Host, path: string): Promise { let o: Record try { o = JSON.parse(text) } catch { return { ok: false, why: 'damaged' } } if (!o || typeof o !== 'object') return { ok: false, why: 'damaged' } - // The helper overwrites whatever file is there, so this check is the only thing keeping a newer glowup's tokens from being refreshed into the old format. + // spotify-login writes over whatever file is there, so connect's refusal of a newer file lives here; spotify-refresh refuses one itself. if (typeof o.format === 'number' && o.format > TOKENS_FORMAT) return { ok: false, why: 'newer' } const s = (v: unknown): v is string => typeof v === 'string' && v !== '' const n = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) @@ -60,6 +60,8 @@ export class TokenKeeper { // The helper is the only writer, so the file is the truth, even after exit 3: a racing session may have refreshed first. const again = await readTokens(this.host, this.path) if (again.ok && again.tokens.gen !== t.gen) { this.tokens = again.tokens; return { token: again.tokens.accessToken } } + // replaced by a newer glowup's file, or damaged, while the helper ran: stop spawning it + if (!again.ok && again.why !== 'missing') { this.state = again.why; return { error: again.why } } if (code === 3) { this.state = 'reconnect'; return { error: 'reconnect' } } // offline, or the helper failed: the old token while it lasts, unless Spotify already refused it return !rejected && t.expiresAt > now ? { token: t.accessToken } : { error: 'failed' } diff --git a/test/music-tokens.test.ts b/test/music-tokens.test.ts index a03e0399..fde2261c 100644 --- a/test/music-tokens.test.ts +++ b/test/music-tokens.test.ts @@ -63,12 +63,38 @@ test('exit 3 means Reconnect, unless another session already refreshed', async ( test('a failed refresh keeps the old token until it expires, and is not Reconnect', async () => { const { host } = fakeHost({ files: { [PATH]: file({ expiresAt: 30_000 }) } }) - const keeper = new TokenKeeper(host, PATH, async () => 1) + let calls = 0 + const keeper = new TokenKeeper(host, PATH, async () => { calls++; return 1 }) await keeper.load() expect(await keeper.access(0)).toEqual({ token: 'at-1' }) expect(await keeper.access(40_000)).toEqual({ error: 'failed' }) expect(await keeper.access(0, 'at-1')).toEqual({ error: 'failed' }) expect(keeper.status()).toBe('ok') + expect(calls).toBe(3) +}) + +test('a refresher that rejects is a failed refresh, and the next run spawns the helper again', async () => { + const { host } = fakeHost({ files: { [PATH]: file({ expiresAt: 30_000 }) } }) + let calls = 0 + const keeper = new TokenKeeper(host, PATH, async () => { calls++; throw new Error('spawn failed') }) + await keeper.load() + expect(await keeper.access(40_000)).toEqual({ error: 'failed' }) + expect(await keeper.access(40_000)).toEqual({ error: 'failed' }) + expect(calls).toBe(2) + expect(keeper.status()).toBe('ok') +}) + +test('a file that turns newer or damaged during a refresh is remembered, and the helper is not spawned again', async () => { + for (const why of ['newer', 'damaged'] as const) { + const { host, files } = fakeHost({ files: { [PATH]: file({ expiresAt: 0 }) } }) + let calls = 0 + const keeper = new TokenKeeper(host, PATH, async () => { calls++; files[PATH] = why === 'newer' ? file({ format: 2 }) : '{'; return 0 }) + await keeper.load() + expect(await keeper.access(1000)).toEqual({ error: why }) + expect(keeper.status()).toBe(why) + expect(await keeper.access(1000)).toEqual({ error: why }) + expect(calls).toBe(1) + } }) test('a newer token file is refused and never refreshed', async () => { From 3c8ed7012d1f228244158a3a2b24e54c8032c08a Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Tue, 13 Oct 2026 10:06:27 +0300 Subject: [PATCH 09/22] feat(wiring): Spotify API client and which source the player shows Signed-off-by: NovusEdge --- hooks/music/player.ts | 64 +++++++++++++++++++++++++++ hooks/music/spotify.ts | 57 ++++++++++++++++++++++++ test/music-player.test.ts | 91 ++++++++++++++++++++++++++++++++++++++ test/music-spotify.test.ts | 77 ++++++++++++++++++++++++++++++++ 4 files changed, 289 insertions(+) create mode 100644 hooks/music/player.ts create mode 100644 hooks/music/spotify.ts create mode 100644 test/music-player.test.ts create mode 100644 test/music-spotify.test.ts diff --git a/hooks/music/player.ts b/hooks/music/player.ts new file mode 100644 index 00000000..63099543 --- /dev/null +++ b/hooks/music/player.ts @@ -0,0 +1,64 @@ +// Which Spotify state to show and who runs a command, from the local read and the Web API's last answers. +// JSX-free and engine-free, so it tests on recorded states with any clock. +import { progressAt, SEEK_MS, type Music, type MusicCmd, type OpenMusic, type QueueItem, type Repeat } from './parse.ts' +import type { MusicCaps } from './reader.ts' + +// What the API last said. liked and upNext are keyed by the track URI they were read for, so an answer about +// the last track never lands on this one; on or item absent marks a read that failed and is not tried again for it. +export type Link = { + // the last good GET /me/player; music absent is a 204 + last?: { at: number; music?: OpenMusic } + liked?: { uri: string; on?: boolean } + upNext?: { uri: string; item?: QueueItem } +} +export type Route = 'api' | 'apiThenLocal' | 'local' | 'none' +const ALL_REPEAT: Repeat[] = ['off', 'context', 'track'] + +export const apiDelay = (playing: boolean, shown: boolean) => (playing && shown ? 3000 : 15_000) + +// link undefined is not connected, or Reconnect: the local read as before A3. intervalMs is the API's poll interval now. +export function mergeMusic(local: Music, link: Link | undefined, prev: Music | undefined, now: number, intervalMs: number): Music { + if (!link) return local + const last = link.last + let out: Music + if (!last || now - last.at > 2 * intervalMs) out = local.status === 'open' ? { ...local, source: 'local' } : local + else if (!last.music) out = local.status === 'open' ? { ...local, source: 'api' } : { status: 'open', playing: false, ad: false, progressMs: 0, readAt: now, source: 'api' } + else { + const api = last.music + const uri = local.status === 'open' ? local.track?.uri : undefined + // A local read newer than the API's may be a track change the API has not seen yet: local wins until the API reads again. + const here = uri !== undefined && api.device?.type === 'Computer' && (api.track?.uri === uri || (local as OpenMusic).readAt > last.at) + out = here + ? { ...(local as OpenMusic), source: 'api', ...(api.shuffle !== undefined && { shuffle: api.shuffle }), ...(api.repeat && { repeat: api.repeat }), ...(api.volume !== undefined && { volume: api.volume }), device: { ...api.device!, isThisComputer: true } } + : api + } + return keepProgress(withTrackFacts(out, link), prev, now) +} + +function withTrackFacts(m: Music, link: Link): Music { + if (m.status !== 'open' || m.source !== 'api' || !m.track?.uri) return m + const uri = m.track.uri + return { ...m, ...(link.liked?.uri === uri && link.liked.on !== undefined && { liked: link.liked.on }), ...(link.upNext?.uri === uri && link.upNext.item && { upNext: link.upNext.item }) } +} + +// A switch of source for the same track never pulls the bar back: one source can lag the other by a poll. +function keepProgress(m: Music, prev: Music | undefined, now: number): Music { + if (m.status !== 'open' || prev?.status !== 'open' || m.source === prev.source || !m.track?.uri || m.track.uri !== prev.track?.uri) return m + const was = progressAt(prev, now) + return progressAt(m, now) < was - SEEK_MS ? { ...m, progressMs: Math.round(was), readAt: now } : m +} + +// premium false: Spotify answered PREMIUM_REQUIRED once this session, so transport stays local. +export function routeOf(cmd: MusicCmd, m: Music | undefined, premium: boolean): Route { + const o = m?.status === 'open' && m.source === 'api' && m.device ? m : undefined + if (cmd.cmd === 'like') return o?.track?.uri ? 'api' : 'none' + if (cmd.cmd === 'raise' || !o || !premium) return 'local' + // the API can fail where the app on this computer still answers + return o.device!.isThisComputer ? 'apiThenLocal' : 'api' +} + +// While the API runs the player, shuffle, every repeat mode and volume work whatever the local app allows. +export function capsOf(m: Music | undefined, local: MusicCaps | undefined, premium: boolean): MusicCaps | undefined { + const api = premium && m?.status === 'open' && m.source === 'api' && m.device !== undefined + return api ? { repeat: ALL_REPEAT, shuffle: true, raise: local?.raise ?? false, volume: true } : local +} diff --git a/hooks/music/spotify.ts b/hooks/music/spotify.ts new file mode 100644 index 00000000..4dcca379 --- /dev/null +++ b/hooks/music/spotify.ts @@ -0,0 +1,57 @@ +// The Web API client: the bearer token, one refresh and retry on a 401, Retry-After, and one request per endpoint in flight. +import type { Host } from '../host.ts' +import type { OpenMusic } from './parse.ts' +import type { TokenKeeper } from './tokens.ts' +import { apiError, parsePlayer, playerRequest, type ApiError, type ApiRequest } from './webapi.ts' + +export const API_TIMEOUT_MS = 5000 +export type ApiAnswer = { ok: true; status: number; text: string; at: number } | { ok: false; error: ApiError } + +export class SpotifyClient { + private inFlight = new Set() + private until = 0 + // false once a write answered PREMIUM_REQUIRED: transport then goes through the local app for the session + premium = true + constructor(private host: Host, private keeper: TokenKeeper, private now: () => number) {} + + async send(r: ApiRequest): Promise { + if (this.now() < this.until) return { ok: false, error: { kind: 'rate', untilMs: this.until } } + // keyed without the query: the engine request cannot be aborted, so a hung one blocks the next poll of its endpoint instead of piling another on + const key = `${r.method} ${r.url.split('?')[0]}` + if (this.inFlight.has(key)) return { ok: false, error: { kind: 'busy' } } + this.inFlight.add(key) + try { return await this.authorized(r) } finally { this.inFlight.delete(key) } + } + + private async authorized(r: ApiRequest): Promise { + const first = await this.keeper.access(this.now()) + if ('error' in first) return { ok: false, error: first.error === 'failed' ? { kind: 'offline' } : { kind: 'reconnect' } } + let res = await this.fetch(r, first.token) + if (res?.status === 401) { + const again = await this.keeper.access(this.now(), first.token) + if ('error' in again) return { ok: false, error: again.error === 'failed' ? { kind: 'offline' } : { kind: 'reconnect' } } + res = await this.fetch(r, again.token) + } + if (!res) return { ok: false, error: { kind: 'offline' } } + if (res.status >= 200 && res.status < 300) return { ok: true, status: res.status, text: res.text, at: this.now() } + const error = apiError(res.status, res.text, res.headers, this.now()) + if (error.kind === 'rate') this.until = error.untilMs + if (error.kind === 'premium') this.premium = false + if (error.kind === 'reconnect') this.keeper.lost() + return { ok: false, error } + } + + private fetch(r: ApiRequest, token: string) { + return this.host.fetch(r.url, { method: r.method, headers: { Authorization: `Bearer ${token}` }, timeoutMs: API_TIMEOUT_MS }).catch(() => undefined) + } +} + +// music absent is a 204: nothing is active on any device. +export type PlayerRead = { ok: true; at: number; music?: OpenMusic } | { ok: false; error: ApiError } +export async function readPlayer(c: SpotifyClient): Promise { + const a = await c.send(playerRequest()) + if (!a.ok) return a + if (a.status === 204 || !a.text.trim()) return { ok: true, at: a.at } + const music = parsePlayer(a.text, a.at) + return music ? { ok: true, at: a.at, music } : { ok: false, error: { kind: 'server', status: a.status } } +} diff --git a/test/music-player.test.ts b/test/music-player.test.ts new file mode 100644 index 00000000..9609a2e1 --- /dev/null +++ b/test/music-player.test.ts @@ -0,0 +1,91 @@ +import { expect } from 'claude-code/testing' +import { test } from './kit.ts' +import { apiDelay, capsOf, mergeMusic, routeOf, type Link } from '../hooks/music/player.ts' +import type { OpenMusic } from '../hooks/music/parse.ts' +import type { MusicCaps } from '../hooks/music/reader.ts' + +const T1 = { id: '/com/spotify/track/t1', title: 'One', artist: 'A', album: 'X', durationMs: 200_000, uri: 'spotify:track:t1' } +const T2 = { ...T1, id: '/com/spotify/track/t2', title: 'Two', uri: 'spotify:track:t2' } +const apiT1 = { ...T1, id: 'spotify:track:t1' } +const desk = { id: 'd1', name: 'Desktop', type: 'Computer', isThisComputer: false } +const phone = { id: 'p1', name: 'Pixel 9', type: 'Smartphone', isThisComputer: false } +const local = (over: Partial = {}): OpenMusic => ({ status: 'open', track: T1, playing: true, ad: false, progressMs: 10_000, readAt: 1000, ...over }) +const api = (over: Partial = {}): OpenMusic => ({ status: 'open', track: apiT1, playing: true, ad: false, progressMs: 9000, readAt: 1000, source: 'api', shuffle: true, repeat: 'context', volume: 0.5, device: desk, ...over }) +const NEXT = { uri: 'spotify:track:n', title: 'Next', artist: 'B', durationMs: 1 } +const I = 3000 + +test('not connected: the local read as it is', () => { + expect(mergeMusic(local(), undefined, undefined, 1000, I)).toEqual(local()) +}) + +test('this computer active: track and progress from local; shuffle, repeat, volume, device, heart and Up next from the API', () => { + const link: Link = { last: { at: 1000, music: api() }, liked: { uri: 'spotify:track:t1', on: true }, upNext: { uri: 'spotify:track:t1', item: NEXT } } + expect(mergeMusic(local(), link, undefined, 1500, I)).toEqual({ ...local(), source: 'api', shuffle: true, repeat: 'context', volume: 0.5, device: { ...desk, isThisComputer: true }, liked: true, upNext: NEXT }) +}) + +test('another device active: everything from the API, and a heart read for another track is not shown', () => { + const two = { ...apiT1, id: 'spotify:track:t2', uri: 'spotify:track:t2', title: 'Two' } + const link: Link = { last: { at: 1000, music: api({ device: phone, track: two }) }, liked: { uri: 'spotify:track:t1', on: true } } + const m = mergeMusic(local({ playing: false }), link, undefined, 1500, I) as OpenMusic + expect([m.track?.title, m.device?.name, m.liked, m.progressMs, m.readAt]).toEqual(['Two', 'Pixel 9', undefined, 9000, 1000]) +}) + +test('the API lagging a local track change: a newer local read wins until the next API read', () => { + const changed = local({ track: T2, readAt: 2000, progressMs: 0 }) + expect((mergeMusic(changed, { last: { at: 1000, music: api() } }, undefined, 2000, I) as OpenMusic).track?.title).toBe('Two') + // the next API read still names another track on a Computer: another computer plays it + expect((mergeMusic(changed, { last: { at: 2500, music: api() } }, undefined, 2500, I) as OpenMusic).track?.title).toBe('One') +}) + +test('204: local when it is open, else Nothing playing', () => { + const link: Link = { last: { at: 1000 } } + expect(mergeMusic(local(), link, undefined, 1500, I)).toEqual({ ...local(), source: 'api' }) + expect(mergeMusic({ status: 'closed' }, link, undefined, 1500, I)).toEqual({ status: 'open', playing: false, ad: false, progressMs: 0, readAt: 1500, source: 'api' }) +}) + +test('an API read counts for two poll intervals', () => { + const link: Link = { last: { at: 1000, music: api({ device: phone }) } } + expect((mergeMusic(local(), link, undefined, 7000, I) as OpenMusic).source).toBe('api') + expect((mergeMusic(local(), link, undefined, 7001, I) as OpenMusic).source).toBe('local') + expect(mergeMusic({ status: 'closed' }, link, undefined, 7001, I)).toEqual({ status: 'closed' }) +}) + +test('a failing API falls back to local without moving the bar back, and recovers', () => { + // the phone played this track at 60 s; then the API went quiet for more than two polls + const onPhone: Link = { last: { at: 1000, music: api({ device: phone, progressMs: 60_000 }) } } + const shown = mergeMusic(local({ playing: false }), onPhone, undefined, 1000, I) + const fallen = mergeMusic(local({ playing: false, readAt: 8000 }), onPhone, shown, 8000, I) as OpenMusic + expect(fallen.source).toBe('local') + expect(fallen.progressMs).toBe(67_000) + expect(fallen.liked).toBeUndefined() + const back = mergeMusic(local({ playing: false, readAt: 9000 }), { last: { at: 9000, music: api({ device: phone, progressMs: 68_000, readAt: 9000 }) } }, fallen, 9000, I) as OpenMusic + expect([back.source, back.progressMs]).toEqual(['api', 68_000]) +}) + +test('the routing table', () => { + const here = mergeMusic(local(), { last: { at: 1000, music: api() } }, undefined, 1000, I) + const there = mergeMusic(local(), { last: { at: 1000, music: api({ device: phone }) } }, undefined, 1000, I) + const none = mergeMusic(local(), { last: { at: 1000 } }, undefined, 1000, I) + const failing = mergeMusic(local(), { last: { at: 1000, music: api() } }, undefined, 99_000, I) + const next = { cmd: 'next' } as const, like = { cmd: 'like', on: true } as const + expect([routeOf(next, local(), true), routeOf(like, local(), true)]).toEqual(['local', 'none']) + expect([routeOf(next, here, true), routeOf(like, here, true)]).toEqual(['apiThenLocal', 'api']) + expect([routeOf(next, there, true), routeOf(like, there, true)]).toEqual(['api', 'api']) + expect([routeOf(next, none, true), routeOf(like, none, true)]).toEqual(['local', 'none']) + expect([routeOf(next, failing, true), routeOf(like, failing, true)]).toEqual(['local', 'none']) + expect(routeOf({ cmd: 'raise' }, there, true)).toBe('local') + // after PREMIUM_REQUIRED, transport is local for the session; liking still goes to the API + expect([routeOf(next, there, false), routeOf({ cmd: 'seek', ms: 1 }, there, false), routeOf(like, there, false)]).toEqual(['local', 'local', 'api']) +}) + +test('the API widens the controls while it runs the player', () => { + const linux: MusicCaps = { repeat: [], shuffle: false, raise: true, volume: false } + const there = mergeMusic(local(), { last: { at: 1000, music: api({ device: phone }) } }, undefined, 1000, I) + expect(capsOf(there, linux, true)).toEqual({ repeat: ['off', 'context', 'track'], shuffle: true, raise: true, volume: true }) + expect(capsOf(there, linux, false)).toEqual(linux) + expect(capsOf(local(), linux, true)).toEqual(linux) +}) + +test('the API is asked every 3 s while playing in sight, else every 15 s', () => { + expect([apiDelay(true, true), apiDelay(true, false), apiDelay(false, true)]).toEqual([3000, 15_000, 15_000]) +}) diff --git a/test/music-spotify.test.ts b/test/music-spotify.test.ts new file mode 100644 index 00000000..1e59cb01 --- /dev/null +++ b/test/music-spotify.test.ts @@ -0,0 +1,77 @@ +import { expect } from 'claude-code/testing' +import { fakeHost, test, type FakeRequest } from './kit.ts' +import { SpotifyClient, readPlayer } from '../hooks/music/spotify.ts' +import { TokenKeeper, tokenFile, type Refresher } from '../hooks/music/tokens.ts' +import { commandRequest } from '../hooks/music/webapi.ts' + +const PATH = tokenFile('/home/u/.claude') +const file = (over: Record = {}) => JSON.stringify({ format: 1, clientId: 'c'.repeat(32), accessToken: 'at-1', refreshToken: 'rt-1', expiresAt: 3_600_000, scope: '', connectedAt: 0, gen: 1, ...over }) +const PLAYER = JSON.stringify({ device: { id: 'p', name: 'Pixel 9', type: 'Smartphone' }, is_playing: true, progress_ms: 1000, currently_playing_type: 'track', item: { type: 'track', uri: 'spotify:track:t', name: 'T', duration_ms: 9000, artists: [], album: { name: '' } } }) +type Reply = { status: number; text?: string; headers?: Record } + +async function rig(reply: (req: FakeRequest) => Reply | Promise, refresh: (files: Record) => Refresher = () => async () => 0) { + let now = 1000 + const kit = fakeHost({ files: { [PATH]: file() }, http: async req => { const r = await reply(req); return { status: r.status, text: r.text ?? '', headers: r.headers ?? {} } } }) + const keeper = new TokenKeeper(kit.host, PATH, refresh(kit.files)) + await keeper.load() + return { ...kit, keeper, client: new SpotifyClient(kit.host, keeper, () => now), at: (t: number) => { now = t } } +} +const auth = (reqs: FakeRequest[]) => reqs.map(r => r.headers.Authorization) + +test('a read carries the bearer token and is dated when the answer arrived', async () => { + const r = await rig(() => ({ status: 200, text: PLAYER })) + expect(await readPlayer(r.client)).toMatchObject({ ok: true, at: 1000, music: { readAt: 1000, source: 'api', track: { title: 'T' } } }) + expect(auth(r.requests)).toEqual(['Bearer at-1']) +}) + +test('204 is nothing active, not an error', async () => { + const r = await rig(() => ({ status: 204 })) + expect(await readPlayer(r.client)).toEqual({ ok: true, at: 1000 }) +}) + +test('a 401 refreshes once and retries; a second 401 means Reconnect', async () => { + const fresh = (files: Record): Refresher => async gen => { files[PATH] = file({ accessToken: 'at-2', gen: gen + 1 }); return 0 } + const once = await rig(req => (req.headers.Authorization === 'Bearer at-2' ? { status: 200, text: PLAYER } : { status: 401 }), fresh) + expect((await readPlayer(once.client)).ok).toBe(true) + expect(auth(once.requests)).toEqual(['Bearer at-1', 'Bearer at-2']) + const twice = await rig(() => ({ status: 401 }), fresh) + expect(await readPlayer(twice.client)).toEqual({ ok: false, error: { kind: 'reconnect' } }) + expect(twice.keeper.status()).toBe('reconnect') +}) + +test('429 waits for Retry-After before anything goes out again', async () => { + const r = await rig(() => ({ status: 429, headers: { 'retry-after': '7' } })) + expect(await readPlayer(r.client)).toEqual({ ok: false, error: { kind: 'rate', untilMs: 8000 } }) + r.at(7999) + expect(await readPlayer(r.client)).toEqual({ ok: false, error: { kind: 'rate', untilMs: 8000 } }) + expect(r.requests).toHaveLength(1) + r.at(8000) + await readPlayer(r.client) + expect(r.requests).toHaveLength(2) +}) + +test('a request still out blocks a second one to the same endpoint, not to another', async () => { + let open = () => {} + const gate = new Promise(res => { open = res }) + const r = await rig(async req => { if (req.url.includes('/me/player?')) await gate; return { status: 200, text: req.url.includes('/me/player?') ? PLAYER : '{}' } }) + const first = readPlayer(r.client) + expect(await readPlayer(r.client)).toEqual({ ok: false, error: { kind: 'busy' } }) + expect((await r.client.send({ method: 'GET', url: 'https://api.spotify.com/v1/me' })).ok).toBe(true) + open() + expect((await first).ok).toBe(true) + expect((await readPlayer(r.client)).ok).toBe(true) +}) + +test('a network error is offline', async () => { + const r = await rig(() => { throw new Error('ECONNRESET') }) + expect(await readPlayer(r.client)).toEqual({ ok: false, error: { kind: 'offline' } }) +}) + +test('PREMIUM_REQUIRED turns premium off; another 403 does not', async () => { + const reason = (s: string) => JSON.stringify({ error: { status: 403, message: 'x', reason: s } }) + const r = await rig(req => ({ status: 403, text: reason(req.url.endsWith('/next') ? 'PREMIUM_REQUIRED' : 'UNKNOWN') })) + expect(await r.client.send(commandRequest({ cmd: 'seek', ms: 1 }, true)!)).toEqual({ ok: false, error: { kind: 'refused', reason: 'UNKNOWN' } }) + expect(r.client.premium).toBe(true) + expect(await r.client.send(commandRequest({ cmd: 'next' }, true)!)).toEqual({ ok: false, error: { kind: 'premium' } }) + expect(r.client.premium).toBe(false) +}) From b1c00da5d685af87c3a4240879f14a809c8c8df7 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Tue, 13 Oct 2026 10:20:43 +0300 Subject: [PATCH 10/22] fix(wiring): hold the endpoint until its request settles; lag, floor and staleness rules Signed-off-by: NovusEdge --- hooks/host.ts | 4 +++- hooks/music/player.ts | 17 ++++++++++------- hooks/music/spotify.ts | 17 ++++++++++------- hooks/register.tsx | 2 +- test/kit.ts | 14 ++++++++++---- test/music-player.test.ts | 21 +++++++++++++++++++-- test/music-spotify.test.ts | 23 +++++++++++++++++++++-- 7 files changed, 74 insertions(+), 24 deletions(-) diff --git a/hooks/host.ts b/hooks/host.ts index c1185884..790a7788 100644 --- a/hooks/host.ts +++ b/hooks/host.ts @@ -1,7 +1,9 @@ // The engine as the other modules see it. The loader refuses $ passed across an // import, so register.tsx builds this from $ in hostOf. export type RunResult = { exitCode: number; stdout: string; stderr: string } -export type FetchInit = { method?: string; headers?: Record; body?: string; timeoutMs: number } +export type FetchInit = { method?: string; headers?: Record; body?: string; timeoutMs: number + // called once when the engine's request ends, answered or failed: after timeoutMs the promise has rejected but the request, which cannot be aborted, may still be out + onSettled?: () => void } export type FetchResult = { status: number; headers: Record; text: string } export type Spawned = { done: Promise; kill(): void } export type Host = { diff --git a/hooks/music/player.ts b/hooks/music/player.ts index 63099543..69d346eb 100644 --- a/hooks/music/player.ts +++ b/hooks/music/player.ts @@ -6,8 +6,10 @@ import type { MusicCaps } from './reader.ts' // What the API last said. liked and upNext are keyed by the track URI they were read for, so an answer about // the last track never lands on this one; on or item absent marks a read that failed and is not tried again for it. export type Link = { - // the last good GET /me/player; music absent is a 204 - last?: { at: number; music?: OpenMusic } + // the last good GET /me/player; music absent is a 204. localUri is the local app's track and intervalMs the poll + // interval when it was read: a local read counts as a track change the API has not seen only if its URI differs + // from localUri, and the read stays fresh for two of the slower of intervalMs and the interval now. + last?: { at: number; music?: OpenMusic; localUri?: string; intervalMs?: number } liked?: { uri: string; on?: boolean } upNext?: { uri: string; item?: QueueItem } } @@ -21,13 +23,13 @@ export function mergeMusic(local: Music, link: Link | undefined, prev: Music | u if (!link) return local const last = link.last let out: Music - if (!last || now - last.at > 2 * intervalMs) out = local.status === 'open' ? { ...local, source: 'local' } : local + if (!last || now - last.at > 2 * Math.max(intervalMs, last.intervalMs ?? 0)) out = local.status === 'open' ? { ...local, source: 'local' } : local else if (!last.music) out = local.status === 'open' ? { ...local, source: 'api' } : { status: 'open', playing: false, ad: false, progressMs: 0, readAt: now, source: 'api' } else { const api = last.music const uri = local.status === 'open' ? local.track?.uri : undefined - // A local read newer than the API's may be a track change the API has not seen yet: local wins until the API reads again. - const here = uri !== undefined && api.device?.type === 'Computer' && (api.track?.uri === uri || (local as OpenMusic).readAt > last.at) + // A local track other than the one local showed at the API read may be a change the API has not seen yet: local wins until the API reads again. + const here = uri !== undefined && api.device?.type === 'Computer' && (api.track?.uri === uri || uri !== last.localUri) out = here ? { ...(local as OpenMusic), source: 'api', ...(api.shuffle !== undefined && { shuffle: api.shuffle }), ...(api.repeat && { repeat: api.repeat }), ...(api.volume !== undefined && { volume: api.volume }), device: { ...api.device!, isThisComputer: true } } : api @@ -41,9 +43,10 @@ function withTrackFacts(m: Music, link: Link): Music { return { ...m, ...(link.liked?.uri === uri && link.liked.on !== undefined && { liked: link.liked.on }), ...(link.upNext?.uri === uri && link.upNext.item && { upNext: link.upNext.item }) } } -// A switch of source for the same track never pulls the bar back: one source can lag the other by a poll. +// The bar for the same track never moves back unless the API itself reports it (a seek on another device): the local +// app can lag, or know nothing of a track playing elsewhere, and does so for as long as the API stays quiet. function keepProgress(m: Music, prev: Music | undefined, now: number): Music { - if (m.status !== 'open' || prev?.status !== 'open' || m.source === prev.source || !m.track?.uri || m.track.uri !== prev.track?.uri) return m + if (m.status !== 'open' || prev?.status !== 'open' || (m.source === 'api' && prev.source === 'api') || !m.track?.uri || m.track.uri !== prev.track?.uri) return m const was = progressAt(prev, now) return progressAt(m, now) < was - SEEK_MS ? { ...m, progressMs: Math.round(was), readAt: now } : m } diff --git a/hooks/music/spotify.ts b/hooks/music/spotify.ts index 4dcca379..c63b6f6f 100644 --- a/hooks/music/spotify.ts +++ b/hooks/music/spotify.ts @@ -16,21 +16,24 @@ export class SpotifyClient { async send(r: ApiRequest): Promise { if (this.now() < this.until) return { ok: false, error: { kind: 'rate', untilMs: this.until } } - // keyed without the query: the engine request cannot be aborted, so a hung one blocks the next poll of its endpoint instead of piling another on const key = `${r.method} ${r.url.split('?')[0]}` if (this.inFlight.has(key)) return { ok: false, error: { kind: 'busy' } } this.inFlight.add(key) - try { return await this.authorized(r) } finally { this.inFlight.delete(key) } + // The key is held until the engine's request ends, not until the answer: Host.fetch rejects at the timeout but + // cannot abort it, and a dead endpoint would otherwise get a new request every 5 s without bound. + let open = 1 + const hold = () => { open++; let done = false; return () => { if (!done && !--open) this.inFlight.delete(key); done = true } } + try { return await this.authorized(r, hold) } finally { if (!--open) this.inFlight.delete(key) } } - private async authorized(r: ApiRequest): Promise { + private async authorized(r: ApiRequest, hold: () => () => void): Promise { const first = await this.keeper.access(this.now()) if ('error' in first) return { ok: false, error: first.error === 'failed' ? { kind: 'offline' } : { kind: 'reconnect' } } - let res = await this.fetch(r, first.token) + let res = await this.fetch(r, first.token, hold()) if (res?.status === 401) { const again = await this.keeper.access(this.now(), first.token) if ('error' in again) return { ok: false, error: again.error === 'failed' ? { kind: 'offline' } : { kind: 'reconnect' } } - res = await this.fetch(r, again.token) + res = await this.fetch(r, again.token, hold()) } if (!res) return { ok: false, error: { kind: 'offline' } } if (res.status >= 200 && res.status < 300) return { ok: true, status: res.status, text: res.text, at: this.now() } @@ -41,8 +44,8 @@ export class SpotifyClient { return { ok: false, error } } - private fetch(r: ApiRequest, token: string) { - return this.host.fetch(r.url, { method: r.method, headers: { Authorization: `Bearer ${token}` }, timeoutMs: API_TIMEOUT_MS }).catch(() => undefined) + private fetch(r: ApiRequest, token: string, onSettled: () => void) { + return this.host.fetch(r.url, { method: r.method, headers: { Authorization: `Bearer ${token}` }, timeoutMs: API_TIMEOUT_MS, onSettled }).catch(() => undefined) } } diff --git a/hooks/register.tsx b/hooks/register.tsx index f3b702bc..94ec06f4 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -196,7 +196,7 @@ function hostOf($: Engine): Host { fetch: (url, init) => new Promise((resolve, reject) => { const timer = $.clock.after(init.timeoutMs, () => reject(new Error(`timed out after ${init.timeoutMs / 1000} s`))) $.http.fetch(url, { method: init.method ?? 'GET', ...(init.headers && { headers: init.headers }), ...(init.body !== undefined && { body: init.body }) }) - .then(r => { timer.cancel(); resolve({ status: r.status, headers: r.headers, text: r.text }) }, err => { timer.cancel(); reject(err) }) + .then(r => { timer.cancel(); init.onSettled?.(); resolve({ status: r.status, headers: r.headers, text: r.text }) }, err => { timer.cancel(); init.onSettled?.(); reject(err) }) }), spawn: (argv, onLine) => { const child = $.process.spawn({ argv }) diff --git a/test/kit.ts b/test/kit.ts index 65496a8c..cd16845c 100644 --- a/test/kit.ts +++ b/test/kit.ts @@ -30,6 +30,9 @@ export function fakeHost(opts: { files?: Record; runs?: Record void>() + // the 5 s timeout of every fetch still out: its promise rejects, its http handler keeps running + const expire = () => { for (const f of expiries) f(); expiries.clear() } const host: Host = { run: async (argv, env, timeoutMs) => { ran.push(argv.join(' ')) @@ -46,11 +49,14 @@ export function fakeHost(opts: { files?: Record; runs?: Record [...new Set(Object.keys(files).filter(f => f.startsWith(p + '/')).map(f => f.slice(p.length + 1).split('/')[0]!))], listFiles: async p => Object.keys(files).filter(f => f.startsWith(p + '/')).map(f => ({ name: f.slice(p.length + 1), size: files[f]!.length })).filter(e => !e.name.includes('/')), fetchText: async url => url in (opts.fetches ?? {}) ? { ok: true, status: 200, text: opts.fetches![url]! } : { ok: false, status: 404, text: '' }, - fetch: async (url, init) => { + fetch: (url, init) => { const req: FakeRequest = { method: init.method ?? 'GET', url, headers: init.headers ?? {}, ...(init.body !== undefined && { body: init.body }) } requests.push(req) - if (!opts.http) throw new Error('offline') - return opts.http(req) + return new Promise((resolve, reject) => { + expiries.add(() => reject(new Error('timed out'))) + ;(async () => { if (!opts.http) throw new Error('offline'); return opts.http(req) })() + .then(r => { init.onSettled?.(); resolve(r) }, e => { init.onSettled?.(); reject(e) }) + }) }, spawn: (argv, onLine) => { const key = argv.join(' ') @@ -67,7 +73,7 @@ export function fakeHost(opts: { files?: Record; runs?: Record { + const x = { ...apiT1, id: 'spotify:track:x', uri: 'spotify:track:x', title: 'X' } + const link: Link = { last: { at: 1000, localUri: T1.uri, music: api({ track: x }) } } + const m = mergeMusic(local({ readAt: 1400 }), link, undefined, 1500, I) as OpenMusic + expect([m.track?.title, m.device?.isThisComputer]).toEqual(['X', false]) }) test('204: local when it is open, else Nothing playing', () => { @@ -50,6 +57,13 @@ test('an API read counts for two poll intervals', () => { expect(mergeMusic({ status: 'closed' }, link, undefined, 7001, I)).toEqual({ status: 'closed' }) }) +test('staleness is measured against the slower of the intervals then and now', () => { + // read while polling every 15 s; the pane then opened and the interval shrank to 3 s + const link: Link = { last: { at: 1000, intervalMs: 15_000, music: api({ device: phone }) } } + expect((mergeMusic(local(), link, undefined, 9000, I) as OpenMusic).source).toBe('api') + expect((mergeMusic(local(), link, undefined, 31_001, I) as OpenMusic).source).toBe('local') +}) + test('a failing API falls back to local without moving the bar back, and recovers', () => { // the phone played this track at 60 s; then the API went quiet for more than two polls const onPhone: Link = { last: { at: 1000, music: api({ device: phone, progressMs: 60_000 }) } } @@ -58,7 +72,10 @@ test('a failing API falls back to local without moving the bar back, and recover expect(fallen.source).toBe('local') expect(fallen.progressMs).toBe(67_000) expect(fallen.liked).toBeUndefined() - const back = mergeMusic(local({ playing: false, readAt: 9000 }), { last: { at: 9000, music: api({ device: phone, progressMs: 68_000, readAt: 9000 }) } }, fallen, 9000, I) as OpenMusic + // the floor holds for as long as the fallback lasts + const still = mergeMusic(local({ playing: false, readAt: 8500 }), onPhone, fallen, 8500, I) as OpenMusic + expect([still.source, still.progressMs]).toEqual(['local', 67_000]) + const back = mergeMusic(local({ playing: false, readAt: 9000 }), { last: { at: 9000, music: api({ device: phone, progressMs: 68_000, readAt: 9000 }) } }, still, 9000, I) as OpenMusic expect([back.source, back.progressMs]).toEqual(['api', 68_000]) }) diff --git a/test/music-spotify.test.ts b/test/music-spotify.test.ts index 1e59cb01..d6aab92a 100644 --- a/test/music-spotify.test.ts +++ b/test/music-spotify.test.ts @@ -16,7 +16,8 @@ async function rig(reply: (req: FakeRequest) => Reply | Promise, refresh: await keeper.load() return { ...kit, keeper, client: new SpotifyClient(kit.host, keeper, () => now), at: (t: number) => { now = t } } } -const auth = (reqs: FakeRequest[]) => reqs.map(r => r.headers.Authorization) +const tick = () => new Promise(res => { setTimeout(res, 0) }) +const auth =(reqs: FakeRequest[]) => reqs.map(r => r.headers.Authorization) test('a read carries the bearer token and is dated when the answer arrived', async () => { const r = await rig(() => ({ status: 200, text: PLAYER })) @@ -62,9 +63,27 @@ test('a request still out blocks a second one to the same endpoint, not to anoth expect((await readPlayer(r.client)).ok).toBe(true) }) -test('a network error is offline', async () => { +test('a network error is offline, and the next read goes out', async () => { const r = await rig(() => { throw new Error('ECONNRESET') }) expect(await readPlayer(r.client)).toEqual({ ok: false, error: { kind: 'offline' } }) + expect(await readPlayer(r.client)).toEqual({ ok: false, error: { kind: 'offline' } }) + expect(r.requests).toHaveLength(2) +}) + +test('an endpoint stays busy after the 5 s offline until the engine request settles', async () => { + let open = () => {} + const gate = new Promise(res => { open = res }) + const r = await rig(async () => { await gate; return { status: 200, text: PLAYER } }) + const first = readPlayer(r.client) + await tick() + r.expire() + expect(await first).toEqual({ ok: false, error: { kind: 'offline' } }) + expect(await readPlayer(r.client)).toEqual({ ok: false, error: { kind: 'busy' } }) + expect(r.requests).toHaveLength(1) + open() + await tick() + expect((await readPlayer(r.client)).ok).toBe(true) + expect(r.requests).toHaveLength(2) }) test('PREMIUM_REQUIRED turns premium off; another 403 does not', async () => { From f3dee8dcc0de1bed32ba6d90023091e3f1a61d4f Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Tue, 13 Oct 2026 10:25:29 +0300 Subject: [PATCH 11/22] fix(wiring): the bar follows a real seek during fallback Signed-off-by: NovusEdge --- hooks/music/parse.ts | 2 ++ hooks/music/player.ts | 9 ++++++--- test/music-player.test.ts | 3 +++ test/music-spotify.test.ts | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/hooks/music/parse.ts b/hooks/music/parse.ts index fe03c94e..9cf30ce1 100644 --- a/hooks/music/parse.ts +++ b/hooks/music/parse.ts @@ -9,6 +9,8 @@ export type QueueItem = { uri: string; title: string; artist: string; durationMs export type OpenMusic = { status: 'open'; track?: Track; playing: boolean; ad: boolean; progressMs: number; readAt: number; shuffle?: boolean; repeat?: Repeat; volume?: number source?: 'api' | 'local'; device?: Device; liked?: boolean; upNext?: QueueItem + // set with source local when progressMs was raised above the local read's own position: that raw position + held?: number } export type Music = { status: 'closed' } | { status: 'unsupported'; hint: string } | OpenMusic export type MusicCmd = diff --git a/hooks/music/player.ts b/hooks/music/player.ts index 69d346eb..7c3d9eaa 100644 --- a/hooks/music/player.ts +++ b/hooks/music/player.ts @@ -43,12 +43,15 @@ function withTrackFacts(m: Music, link: Link): Music { return { ...m, ...(link.liked?.uri === uri && link.liked.on !== undefined && { liked: link.liked.on }), ...(link.upNext?.uri === uri && link.upNext.item && { upNext: link.upNext.item }) } } -// The bar for the same track never moves back unless the API itself reports it (a seek on another device): the local -// app can lag, or know nothing of a track playing elsewhere, and does so for as long as the API stays quiet. +// A switch of source for the same track never pulls the bar back: one source can lag the other by a poll. When the +// API goes quiet the floor stays for as long as the local read is the one it was taken against (held): an idle or +// stale app reports the same position again, a changed one is a real seek or playback and is followed. function keepProgress(m: Music, prev: Music | undefined, now: number): Music { if (m.status !== 'open' || prev?.status !== 'open' || (m.source === 'api' && prev.source === 'api') || !m.track?.uri || m.track.uri !== prev.track?.uri) return m + if (m.source === 'local' && prev.source === 'local' && m.progressMs !== prev.held) return m const was = progressAt(prev, now) - return progressAt(m, now) < was - SEEK_MS ? { ...m, progressMs: Math.round(was), readAt: now } : m + if (progressAt(m, now) >= was - SEEK_MS) return m + return { ...m, progressMs: Math.round(was), readAt: now, ...(m.source === 'local' && { held: m.progressMs }) } } // premium false: Spotify answered PREMIUM_REQUIRED once this session, so transport stays local. diff --git a/test/music-player.test.ts b/test/music-player.test.ts index bac1f857..ecc3d6dd 100644 --- a/test/music-player.test.ts +++ b/test/music-player.test.ts @@ -75,6 +75,9 @@ test('a failing API falls back to local without moving the bar back, and recover // the floor holds for as long as the fallback lasts const still = mergeMusic(local({ playing: false, readAt: 8500 }), onPhone, fallen, 8500, I) as OpenMusic expect([still.source, still.progressMs]).toEqual(['local', 67_000]) + // a local read at another position is a real seek back: followed + const sought = mergeMusic(local({ playing: false, readAt: 8700, progressMs: 20_000 }), onPhone, still, 8700, I) as OpenMusic + expect([sought.source, sought.progressMs, sought.held]).toEqual(['local', 20_000, undefined]) const back = mergeMusic(local({ playing: false, readAt: 9000 }), { last: { at: 9000, music: api({ device: phone, progressMs: 68_000, readAt: 9000 }) } }, still, 9000, I) as OpenMusic expect([back.source, back.progressMs]).toEqual(['api', 68_000]) }) diff --git a/test/music-spotify.test.ts b/test/music-spotify.test.ts index d6aab92a..8e54f6c8 100644 --- a/test/music-spotify.test.ts +++ b/test/music-spotify.test.ts @@ -17,7 +17,7 @@ async function rig(reply: (req: FakeRequest) => Reply | Promise, refresh: return { ...kit, keeper, client: new SpotifyClient(kit.host, keeper, () => now), at: (t: number) => { now = t } } } const tick = () => new Promise(res => { setTimeout(res, 0) }) -const auth =(reqs: FakeRequest[]) => reqs.map(r => r.headers.Authorization) +const auth = (reqs: FakeRequest[]) => reqs.map(r => r.headers.Authorization) test('a read carries the bearer token and is dated when the answer arrived', async () => { const r = await rig(() => ({ status: 200, text: PLAYER })) From b5fa00eba41c99a12d3c503027e5c409c569bc30 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Tue, 13 Oct 2026 10:50:19 +0300 Subject: [PATCH 12/22] feat(pane): the heart, Up next, and the Spotify connect column Signed-off-by: NovusEdge --- hooks/musicplayer.tsx | 95 ++++++++++++++++++++++++++++++++++------ hooks/pane.tsx | 31 +++++++++---- test/musicplayer.test.ts | 65 ++++++++++++++++++++++++++- test/pane.test.ts | 24 ++++++++++ 4 files changed, 191 insertions(+), 24 deletions(-) diff --git a/hooks/musicplayer.tsx b/hooks/musicplayer.tsx index cb45be3c..d2896cc2 100644 --- a/hooks/musicplayer.tsx +++ b/hooks/musicplayer.tsx @@ -2,11 +2,11 @@ import type { Model } from './model.ts' import type { Theme } from './themes.ts' import { fit, renderSegs, type Seg } from './layout.tsx' -import { progressAt, type MusicCmd } from './music/parse.ts' +import { progressAt, type Music, type MusicCmd } from './music/parse.ts' import { barRow, type SeekBarProps } from './music/seek.ts' import { coverPlaceholder } from './music/art.ts' import type { MusicCaps } from './music/reader.ts' -import { musicControls } from './pane.tsx' +import { musicControls, upNextLine } from './pane.tsx' export const PLAYER_ID = 'glowup-music' // The dock grants about 88 columns and inline 13 to 20 rows, so these fit the grants seen, not the 110x24 asked for. @@ -15,31 +15,50 @@ const LEFT_COLS = 30, SIDE_MIN = 80, GAP = 2 // volume bars, the controls margin and up to three wrapped button rows. export const playerCoverSize = (bodyRows: number): 0 | 8 | 20 => (bodyRows >= 20 ? 20 : bodyRows >= 10 ? 8 : 0) export const CONNECT_PITCH = ['Connect Spotify to see your queue, recent tracks, playlists and devices.', 'Needs Spotify Premium and a free developer app.'] as const +export const DASHBOARD_URL = 'https://developer.spotify.com/dashboard' +// Registered without a port: Spotify then accepts the helper's loopback redirect on any port. +export const REDIRECT_URI = 'http://127.0.0.1/callback' +export const CONNECT_STEPS = [`Open ${DASHBOARD_URL} and create an app.`, 'Tick "Web API".', `Add the redirect URI ${REDIRECT_URI}.`, 'Paste the Client ID below.'] as const + +// The right column: the pitch, the connect flow's two steps, and the resting states it ends in. +export type AccountView = + | { step: 'pitch' } + | { step: 'steps'; error?: string } + | { step: 'waiting'; authUrl?: string; notice?: string } + | { step: 'connected'; name?: string } + | { step: 'reconnect' } + | { step: 'refused'; text: string } +type Els = { Box: any; Text: any; Button: any; Input?: any } export type PlayerNote = { text: string; tone: 'ok' | 'error' } -export type PlayerView = { width: number; bodyRows: number; cover?: Seg[][]; caps?: MusicCaps; note?: PlayerNote } -export type PlayerExtra = { onMusic: (cmd: MusicCmd) => void; onCopy: (url: string) => void; seek?: (props: SeekBarProps) => unknown; volume?: (props: SeekBarProps) => unknown } +export type PlayerView = { width: number; bodyRows: number; cover?: Seg[][]; caps?: MusicCaps; note?: PlayerNote; account?: AccountView } +export type PlayerExtra = { + onMusic: (cmd: MusicCmd) => void; onCopy: (url: string) => void; seek?: (props: SeekBarProps) => unknown; volume?: (props: SeekBarProps) => unknown + onConnect?: () => void; onClientId?: (id: string) => void; onPaste?: (text: string) => void; onCancel?: () => void +} -export function renderPlayer(els: { Box: any; Text: any; Button: any }, m: Model, t: Theme, v: PlayerView, now: number, extra: PlayerExtra) { +// State, note and account lines wrap, since a 30-column left column truncates "Open Spotify to see what's playing." +const wrapped = (els: Els, text: string, color: string, key: string) => {text} + +export function renderPlayer(els: Els, m: Model, t: Theme, v: PlayerView, now: number, extra: PlayerExtra) { const { Box, Text, Button } = els const c = t.colors, s = m.music ?? { status: 'closed' as const } const side = v.width >= SIDE_MIN const lw = side ? LEFT_COLS : v.width const row = (segs: Seg[], key: string) => renderSegs(els, fit(segs, lw), key) - // State and note lines wrap, since a 30-column left column truncates "Open Spotify to see what's playing." - const wrapped = (text: string, color: string, key: string) => {text} const left: unknown[] = [] - if (s.status === 'unsupported') left.push(wrapped(s.hint, c.dim, 'state')) - else if (s.status === 'closed') left.push(wrapped("Open Spotify to see what's playing.", c.dim, 'state')) - else if (s.ad) left.push(wrapped('Ad', c.dim, 'state')) - else if (!s.track) left.push(wrapped('Nothing playing.', c.dim, 'state')) + if (s.status === 'unsupported') left.push(wrapped(els, s.hint, c.dim, 'state')) + else if (s.status === 'closed') left.push(wrapped(els, "Open Spotify to see what's playing.", c.dim, 'state')) + else if (s.ad) left.push(wrapped(els, 'Ad', c.dim, 'state')) + else if (!s.track) left.push(wrapped(els, 'Nothing playing.', c.dim, 'state')) else { const track = s.track, size = playerCoverSize(v.bodyRows) if (size > 0) { ;(v.cover ?? coverPlaceholder(size, size / 2, c.accent)).forEach((r, i) => left.push(renderSegs(els, r, 'cover' + i))) left.push(renderSegs(els, [], 'cover-gap')) } - left.push(row([{ text: track.title, color: c.text, bold: true }], 'title')) + const heart: Seg[] = s.liked === undefined ? [] : [{ text: ' ' + (s.liked ? '♥' : '♡'), color: s.liked ? c.accent : c.dim }] + left.push(row([...fit([{ text: track.title, color: c.text, bold: true }], lw - (heart.length ? 2 : 0)), ...heart], 'title')) if (track.artist) left.push(row([{ text: track.artist, color: c.dim }], 'artist')) if (track.album) left.push(row([{ text: track.album, color: c.dim }], 'album')) const colors = { text: c.text, accent: c.accent, faint: c.faint, dim: c.dim } @@ -55,11 +74,11 @@ export function renderPlayer(els: { Box: any; Text: any; Button: any }, m: Model // wraps at the column's width, so a narrow column takes two rows of buttons rather than overflowing left.push({buttons}) } - if (v.note) left.push(wrapped(v.note.text, v.note.tone === 'error' ? c.fail : c.dim, 'note')) + if (v.note) left.push(wrapped(els, v.note.text, v.note.tone === 'error' ? c.fail : c.dim, 'note')) const rw = side ? Math.max(1, v.width - LEFT_COLS - GAP) : v.width const right = ( - {CONNECT_PITCH.map((l, i) => {l})} + {accountRows(els, t, v.account ?? { step: 'pitch' }, s, extra) as any} ) return ( @@ -69,3 +88,51 @@ export function renderPlayer(els: { Box: any; Text: any; Button: any }, m: Model ) } + +function accountRows(els: Els, t: Theme, a: AccountView, s: Music, extra: PlayerExtra): unknown[] { + const { Box, Text, Button, Input } = els, c = t.colors + // keyed wrap-* so the row-width tests leave it out, as they do the pitch + const line = (text: string, key: string, color: string = c.dim) => wrapped(els, text, color, 'wrap-' + key) + const button = (key: string, label: string, hotkey: string, onPress: () => void) =>