core: drop curl dependencies, weather: bump API timeouts (#2430)

This commit is contained in:
bbedward committed 2026-08-26 14:58:17 -04:00
1 parent 67968aef79
commit 662dffc141
14 files changed
+523 -139

No files matched your search

+34 -47
View File
@@ -3,19 +3,19 @@ package main
import (
"context"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/netfetch"
"github.com/spf13/cobra"
)
var dlOutput string
var dlUserAgent string
var dlHeaders []string
var dlTimeout int
var dlConnectTimeout int
var dlIPv4Only bool
var dlCmd = &cobra.Command{
@@ -33,65 +33,52 @@ var dlCmd = &cobra.Command{
func init() {
dlCmd.Flags().StringVarP(&dlOutput, "output", "o", "", "Output file path (default: stdout)")
dlCmd.Flags().StringVar(&dlUserAgent, "user-agent", "", "Custom User-Agent header")
dlCmd.Flags().IntVar(&dlTimeout, "timeout", 10, "Request timeout in seconds")
dlCmd.Flags().StringArrayVarP(&dlHeaders, "header", "H", nil, "Extra request header as 'Name: value' (repeatable)")
dlCmd.Flags().IntVar(&dlTimeout, "timeout", 10, "Total request timeout in seconds")
dlCmd.Flags().IntVar(&dlConnectTimeout, "connect-timeout", 5, "Connection timeout in seconds")
dlCmd.Flags().BoolVarP(&dlIPv4Only, "ipv4", "4", false, "Force IPv4 only")
}
func runDownload(url string) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(dlTimeout)*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("invalid request: %w", err)
func parseHeaders(raw []string) (map[string]string, error) {
if len(raw) == 0 {
return nil, nil
}
switch {
case dlUserAgent != "":
req.Header.Set("User-Agent", dlUserAgent)
default:
req.Header.Set("User-Agent", "DankMaterialShell/1.0 (Linux)")
}
dialer := &net.Dialer{Timeout: 5 * time.Second}
transport := &http.Transport{DialContext: dialer.DialContext}
if dlIPv4Only {
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.DialContext(ctx, "tcp4", addr)
headers := make(map[string]string, len(raw))
for _, entry := range raw {
name, value, found := strings.Cut(entry, ":")
if !found {
return nil, fmt.Errorf("invalid header %q: expected 'Name: value'", entry)
}
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("invalid header %q: empty name", entry)
}
headers[name] = strings.TrimSpace(value)
}
client := &http.Client{Transport: transport}
return headers, nil
}
resp, err := client.Do(req)
func runDownload(url string) error {
headers, err := parseHeaders(dlHeaders)
if err != nil {
return fmt.Errorf("download failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
if dlOutput == "" {
_, err = io.Copy(os.Stdout, resp.Body)
return err
}
if dir := filepath.Dir(dlOutput); dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("mkdir failed: %w", err)
}
opts := netfetch.Options{
Headers: headers,
UserAgent: dlUserAgent,
Timeout: time.Duration(dlTimeout) * time.Second,
ConnectTimeout: time.Duration(dlConnectTimeout) * time.Second,
IPv4Only: dlIPv4Only,
}
f, err := os.Create(dlOutput)
if err != nil {
return fmt.Errorf("create failed: %w", err)
if dlOutput == "" {
return netfetch.ToWriter(context.Background(), url, opts, os.Stdout)
}
defer f.Close()
if _, err := io.Copy(f, resp.Body); err != nil {
os.Remove(dlOutput)
return fmt.Errorf("write failed: %w", err)
if err := netfetch.ToFile(context.Background(), url, opts, dlOutput); err != nil {
return err
}
fmt.Println(dlOutput)
+4 -21
View File
@@ -16,6 +16,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/netfetch"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
@@ -412,27 +413,11 @@ func updateDMSBinary() error {
}
fmt.Println("Fetching latest release version...")
cmd := exec.Command("curl", "-s", "https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest")
output, err := cmd.Output()
version, err := netfetch.LatestReleaseTag(context.Background())
if err != nil {
return fmt.Errorf("failed to fetch latest release: %w", err)
}
version := ""
for line := range strings.SplitSeq(string(output), "\n") {
if strings.Contains(line, "\"tag_name\"") {
parts := strings.Split(line, "\"")
if len(parts) >= 4 {
version = parts[3]
break
}
}
}
if version == "" {
return fmt.Errorf("could not determine latest version")
}
fmt.Printf("Latest version: %s\n", version)
tempDir, err := os.MkdirTemp("", "dms-update-*")
@@ -448,14 +433,12 @@ func updateDMSBinary() error {
checksumPath := filepath.Join(tempDir, "dms.gz.sha256")
fmt.Println("Downloading dms binary...")
downloadCmd := exec.Command("curl", "-L", binaryURL, "-o", binaryPath)
if err := downloadCmd.Run(); err != nil {
if err := netfetch.ToFile(context.Background(), binaryURL, netfetch.Options{Timeout: 5 * time.Minute}, binaryPath); err != nil {
return fmt.Errorf("failed to download binary: %w", err)
}
fmt.Println("Downloading checksum...")
downloadCmd = exec.Command("curl", "-L", checksumURL, "-o", checksumPath)
if err := downloadCmd.Run(); err != nil {
if err := netfetch.ToFile(context.Background(), checksumURL, netfetch.Options{Timeout: 30 * time.Second}, checksumPath); err != nil {
return fmt.Errorf("failed to download checksum: %w", err)
}
+3 -10
View File
@@ -14,6 +14,7 @@ import (
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/netfetch"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
)
@@ -661,17 +662,10 @@ func (b *BaseDistribution) installDMSBinary(ctx context.Context, sudoPassword st
CommandInfo: fmt.Sprintf("Downloading dms-%s.gz", arch),
}
// Get latest release version
latestVersionCmd := exec.CommandContext(ctx, "bash", "-c",
`curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/'`)
versionOutput, err := latestVersionCmd.Output()
version, err := netfetch.LatestReleaseTag(ctx)
if err != nil {
return fmt.Errorf("failed to get latest DMS version: %w", err)
}
version := strings.TrimSpace(string(versionOutput))
if version == "" {
return fmt.Errorf("could not determine latest DMS version")
}
homeDir, err := os.UserHomeDir()
if err != nil {
@@ -687,8 +681,7 @@ func (b *BaseDistribution) installDMSBinary(ctx context.Context, sudoPassword st
downloadURL := fmt.Sprintf("https://github.com/AvengeMedia/DankMaterialShell/releases/download/%s/dms-cli-%s.gz", version, arch)
gzPath := filepath.Join(tmpDir, "dms.gz")
downloadCmd := exec.CommandContext(ctx, "curl", "-L", downloadURL, "-o", gzPath)
if err := downloadCmd.Run(); err != nil {
if err := netfetch.ToFile(ctx, downloadURL, netfetch.Options{Timeout: 5 * time.Minute}, gzPath); err != nil {
return fmt.Errorf("failed to download DMS binary: %w", err)
}
+17 -6
View File
@@ -3,6 +3,7 @@ package distros
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
@@ -267,12 +268,12 @@ func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
Step: "Installing development dependencies...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get install -y curl wget git cmake ninja-build pkg-config gnupg libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev",
CommandInfo: "sudo apt-get install -y curl wget git cmake ninja-build pkg-config libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev",
LogOutput: "Installing additional development tools",
}
devToolsCmd := privesc.ExecCommand(ctx, sudoPassword,
"DEBIAN_FRONTEND=noninteractive apt-get install -y curl wget git cmake ninja-build pkg-config gnupg libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev libjpeg-dev libpugixml-dev")
"DEBIAN_FRONTEND=noninteractive apt-get install -y curl wget git cmake ninja-build pkg-config libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev libjpeg-dev libpugixml-dev")
if err := d.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil {
return fmt.Errorf("failed to install development tools: %w", err)
}
@@ -505,12 +506,22 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
Progress: 0.18,
Step: fmt.Sprintf("Adding OBS GPG key for %s...", pkg.RepoURL),
NeedsSudo: true,
CommandInfo: fmt.Sprintf("curl & gpg to add key for %s", pkg.RepoURL),
CommandInfo: fmt.Sprintf("install keyring for %s", pkg.RepoURL),
}
keyCmd := fmt.Sprintf("bash -c 'rm -f %s && curl -fsSL %s/Release.key | gpg --batch --dearmor -o %s'", keyringPath, baseURL, keyringPath)
cmd := privesc.ExecCommand(ctx, sudoPassword, keyCmd)
if err := d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.18, 0.20); err != nil {
keyring, err := fetchDearmoredKey(ctx, baseURL+"/Release.key")
if err != nil {
return fmt.Errorf("failed to add OBS GPG key for %s: %w", pkg.RepoURL, err)
}
tmpKeyring, err := writeTempKeyring(keyring)
if err != nil {
return fmt.Errorf("failed to add OBS GPG key for %s: %w", pkg.RepoURL, err)
}
cmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("install -m 0644 %s %s", tmpKeyring, keyringPath))
err = d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.18, 0.20)
os.Remove(tmpKeyring)
if err != nil {
return fmt.Errorf("failed to add OBS GPG key for %s: %w", pkg.RepoURL, err)
}
+135
View File
@@ -0,0 +1,135 @@
package distros
import (
"context"
"encoding/base64"
"fmt"
"os"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/netfetch"
)
const (
crc24Init = 0xB704CE
crc24Poly = 0x1864CFB
)
func crc24(data []byte) uint32 {
crc := uint32(crc24Init)
for _, b := range data {
crc ^= uint32(b) << 16
for range 8 {
crc <<= 1
if crc&0x1000000 != 0 {
crc ^= crc24Poly
}
}
}
return crc & 0xFFFFFF
}
// dearmorPGP converts an ASCII-armored key (RFC 4880 section 6.2) into the
// binary form apt wants behind signed-by, matching `gpg --batch --dearmor`.
func dearmorPGP(armored []byte) ([]byte, error) {
lines := strings.Split(strings.ReplaceAll(string(armored), "\r\n", "\n"), "\n")
start := -1
for i, line := range lines {
if strings.HasPrefix(strings.TrimSpace(line), "-----BEGIN PGP ") {
start = i + 1
break
}
}
if start < 0 {
return nil, fmt.Errorf("no PGP armor header found")
}
// Armor headers ("Version: ...") run until the first blank line; a block
// with no headers starts its payload immediately.
for start < len(lines) && strings.Contains(lines[start], ": ") {
start++
}
for start < len(lines) && strings.TrimSpace(lines[start]) == "" {
start++
}
var payload strings.Builder
checksum := ""
ended := false
for _, line := range lines[start:] {
trimmed := strings.TrimSpace(line)
switch {
case trimmed == "":
continue
case strings.HasPrefix(trimmed, "-----END PGP "):
ended = true
case strings.HasPrefix(trimmed, "="):
checksum = trimmed[1:]
default:
payload.WriteString(trimmed)
}
if ended {
break
}
}
if !ended {
return nil, fmt.Errorf("unterminated PGP armor block")
}
decoded, err := base64.StdEncoding.DecodeString(payload.String())
if err != nil {
return nil, fmt.Errorf("invalid base64 in PGP armor: %w", err)
}
if len(decoded) == 0 {
return nil, fmt.Errorf("PGP armor block is empty")
}
if checksum == "" {
return decoded, nil
}
want, err := base64.StdEncoding.DecodeString(checksum)
if err != nil || len(want) != 3 {
return nil, fmt.Errorf("invalid PGP armor checksum %q", checksum)
}
got := crc24(decoded)
if uint32(want[0])<<16|uint32(want[1])<<8|uint32(want[2]) != got {
return nil, fmt.Errorf("PGP armor checksum mismatch")
}
return decoded, nil
}
func fetchDearmoredKey(ctx context.Context, url string) ([]byte, error) {
armored, err := netfetch.Bytes(ctx, url, netfetch.Options{Timeout: 30 * time.Second})
if err != nil {
return nil, fmt.Errorf("failed to fetch %s: %w", url, err)
}
keyring, err := dearmorPGP(armored)
if err != nil {
return nil, fmt.Errorf("failed to dearmor key from %s: %w", url, err)
}
return keyring, nil
}
// writeTempKeyring stages the keyring somewhere the unprivileged process can
// write, for a privileged install into /etc/apt/keyrings.
func writeTempKeyring(keyring []byte) (string, error) {
f, err := os.CreateTemp("", "dms-repo-key-*.gpg")
if err != nil {
return "", fmt.Errorf("failed to create temp keyring: %w", err)
}
defer f.Close()
if _, err := f.Write(keyring); err != nil {
os.Remove(f.Name())
return "", fmt.Errorf("failed to write temp keyring: %w", err)
}
if err := f.Chmod(0o644); err != nil {
os.Remove(f.Name())
return "", fmt.Errorf("failed to chmod temp keyring: %w", err)
}
return f.Name(), nil
}
+67
View File
@@ -0,0 +1,67 @@
package distros
import (
"bytes"
"os"
"strings"
"testing"
)
// The golden file was produced by `gpg --batch --dearmor` against the armored
// fixture, so this pins dearmorPGP to gpg's output byte for byte.
func TestDearmorPGPMatchesGPGGolden(t *testing.T) {
armored, err := os.ReadFile("testdata/obs-release.asc")
if err != nil {
t.Fatalf("read fixture: %v", err)
}
want, err := os.ReadFile("testdata/obs-release.gpg")
if err != nil {
t.Fatalf("read golden: %v", err)
}
got, err := dearmorPGP(armored)
if err != nil {
t.Fatalf("dearmorPGP: %v", err)
}
if !bytes.Equal(got, want) {
t.Fatalf("got %d bytes, want %d", len(got), len(want))
}
}
func TestDearmorPGPAcceptsCRLF(t *testing.T) {
armored, err := os.ReadFile("testdata/obs-release.asc")
if err != nil {
t.Fatalf("read fixture: %v", err)
}
want, err := dearmorPGP(armored)
if err != nil {
t.Fatalf("dearmorPGP: %v", err)
}
crlf := []byte(strings.ReplaceAll(string(armored), "\n", "\r\n"))
got, err := dearmorPGP(crlf)
if err != nil {
t.Fatalf("dearmorPGP on CRLF input: %v", err)
}
if !bytes.Equal(got, want) {
t.Fatal("CRLF input decoded differently")
}
}
func TestDearmorPGPRejects(t *testing.T) {
tests := map[string]string{
"no header": "mQINBAAA\n",
"unterminated": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBAAA\n",
"bad base64": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n!!!!\n-----END PGP PUBLIC KEY BLOCK-----\n",
"empty payload": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n-----END PGP PUBLIC KEY BLOCK-----\n",
"checksum mismatch": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBAAA\n=AAAA\n-----END PGP PUBLIC KEY BLOCK-----\n",
}
for name, input := range tests {
t.Run(name, func(t *testing.T) {
if _, err := dearmorPGP([]byte(input)); err == nil {
t.Fatal("expected an error")
}
})
}
}
+41
View File
@@ -0,0 +1,41 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
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
=kr/Y
-----END PGP PUBLIC KEY BLOCK-----
Binary file not shown.
+145
View File
@@ -0,0 +1,145 @@
package netfetch
import (
"context"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"time"
)
const DefaultUserAgent = "DankMaterialShell/1.0 (Linux)"
type Options struct {
Headers map[string]string
UserAgent string
Timeout time.Duration
ConnectTimeout time.Duration
IPv4Only bool
}
func (o Options) client() *http.Client {
connect := o.ConnectTimeout
if connect <= 0 {
connect = 5 * time.Second
}
dialer := &net.Dialer{Timeout: connect}
transport := &http.Transport{DialContext: dialer.DialContext}
if o.IPv4Only {
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.DialContext(ctx, "tcp4", addr)
}
}
return &http.Client{Transport: transport}
}
func (o Options) request(ctx context.Context, url string) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
agent := o.UserAgent
if agent == "" {
agent = DefaultUserAgent
}
req.Header.Set("User-Agent", agent)
for name, value := range o.Headers {
req.Header.Set(name, value)
}
return req, nil
}
// Open issues the GET and hands back the body on a 2xx. The caller closes it,
// which is also what releases the timeout.
func Open(ctx context.Context, url string, opts Options) (io.ReadCloser, error) {
if opts.Timeout <= 0 {
return open(ctx, url, opts)
}
ctx, cancel := context.WithTimeout(ctx, opts.Timeout)
body, err := open(ctx, url, opts)
if err != nil {
cancel()
return nil, err
}
return &cancelOnClose{ReadCloser: body, cancel: cancel}, nil
}
type cancelOnClose struct {
io.ReadCloser
cancel context.CancelFunc
}
func (c *cancelOnClose) Close() error {
err := c.ReadCloser.Close()
c.cancel()
return err
}
func open(ctx context.Context, url string, opts Options) (io.ReadCloser, error) {
req, err := opts.request(ctx, url)
if err != nil {
return nil, err
}
resp, err := opts.client().Do(req)
if err != nil {
return nil, fmt.Errorf("download failed: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
resp.Body.Close()
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
return resp.Body, nil
}
// Bytes reads the whole response into memory.
func Bytes(ctx context.Context, url string, opts Options) ([]byte, error) {
body, err := Open(ctx, url, opts)
if err != nil {
return nil, err
}
defer body.Close()
return io.ReadAll(body)
}
// ToWriter streams the response into w.
func ToWriter(ctx context.Context, url string, opts Options, w io.Writer) error {
body, err := Open(ctx, url, opts)
if err != nil {
return err
}
defer body.Close()
_, err = io.Copy(w, body)
return err
}
// ToFile streams the response to path, creating parent directories and leaving
// nothing behind on failure.
func ToFile(ctx context.Context, url string, opts Options, path string) error {
if dir := filepath.Dir(path); dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("mkdir failed: %w", err)
}
}
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("create failed: %w", err)
}
defer f.Close()
if err := ToWriter(ctx, url, opts, f); err != nil {
os.Remove(path)
return err
}
return nil
}
+29
View File
@@ -0,0 +1,29 @@
package netfetch
import (
"context"
"encoding/json"
"fmt"
"time"
)
const latestReleaseURL = "https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest"
// LatestReleaseTag returns the tag of the newest DankMaterialShell release.
func LatestReleaseTag(ctx context.Context) (string, error) {
body, err := Bytes(ctx, latestReleaseURL, Options{Timeout: 10 * time.Second})
if err != nil {
return "", err
}
var release struct {
TagName string `json:"tag_name"`
}
if err := json.Unmarshal(body, &release); err != nil {
return "", fmt.Errorf("failed to parse latest release: %w", err)
}
if release.TagName == "" {
return "", fmt.Errorf("latest release has no tag")
}
return release.TagName, nil
}
+4 -25
View File
@@ -1,7 +1,7 @@
package version
import (
"encoding/json"
"context"
"fmt"
"os"
"os/exec"
@@ -9,6 +9,7 @@ import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/netfetch"
)
type VersionInfo struct {
@@ -26,7 +27,7 @@ type VersionFetcher interface {
GetLatestVersion(dmsPath string) (string, error)
}
// DefaultVersionFetcher is the default implementation that uses git/curl
// DefaultVersionFetcher is the default implementation that uses git and the GitHub API
type DefaultVersionFetcher struct{}
func (d *DefaultVersionFetcher) GetCurrentVersion(dmsPath string) (string, error) {
@@ -135,29 +136,7 @@ func (d *DefaultVersionFetcher) GetLatestVersion(dmsPath string) (string, error)
}
}
// Add timeout to prevent hanging when GitHub is down
cmd := exec.Command("curl", "-s", "--max-time", "5", "https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest")
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to fetch latest release: %w", err)
}
var result struct {
TagName string `json:"tag_name"`
}
if err := json.Unmarshal(output, &result); err != nil {
for line := range strings.SplitSeq(string(output), "\n") {
if strings.Contains(line, "\"tag_name\"") {
parts := strings.Split(line, "\"")
if len(parts) >= 4 {
return parts[3], nil
}
}
}
return "", fmt.Errorf("failed to parse latest version: %w", err)
}
return result.TagName, nil
return netfetch.LatestReleaseTag(context.Background())
}
// defaultFetcher is used by the public functions
+9 -9
View File
@@ -14,7 +14,7 @@ Singleton {
// file:// url of the current album's downloaded animated cover, or empty.
property string animatedArtUrl: ""
readonly property var _curlCmd: ["curl", "-sS", "--fail", "--connect-timeout", "5", "--max-time", "15", "--compressed"]
readonly property var _fetchCmd: [Proc.dmsBin, "dl", "--connect-timeout", "5", "--timeout", "15"]
readonly property string _artDir: Paths.strip(Paths.cache) + "/applemusic-art"
property string _token: ""
// artist\nalbum (lowercased) -> file url or "" for a known miss; never refetched this session.
@@ -90,7 +90,7 @@ Singleton {
// US storefront only (search default and the catalog paths below); albums absent there become misses.
function _search(key, serial) {
const term = encodeURIComponent(_artist + " " + _album);
Proc.runCommand(null, _curlCmd.concat(["https://itunes.apple.com/search?media=music&entity=album&limit=1&term=" + term]), (output, exitCode) => {
Proc.runCommand(null, _fetchCmd.concat(["https://itunes.apple.com/search?media=music&entity=album&limit=1&term=" + term]), (output, exitCode) => {
if (serial !== _serial)
return;
if (exitCode !== 0) {
@@ -124,8 +124,8 @@ Singleton {
// The anonymous web-player JWT sits in the main JS bundle referenced by any album page.
function _fetchToken(pageUrl, callback) {
const script = "p=$(curl -sSfL --compressed \"$1\" | grep -oE '/assets/index~[a-zA-Z0-9]+\\.js' | head -1) && curl -sSfL --compressed \"https://music.apple.com$p\" | grep -oE '\"eyJ[A-Za-z0-9._-]+\"' | head -1 | tr -d '\"'";
Proc.runCommand(null, ["sh", "-c", script, "sh", pageUrl], (output, exitCode) => {
const script = "p=$(\"$0\" dl \"$1\" | grep -oE '/assets/index~[a-zA-Z0-9]+\\.js' | head -1) && \"$0\" dl \"https://music.apple.com$p\" | grep -oE '\"eyJ[A-Za-z0-9._-]+\"' | head -1 | tr -d '\"'";
Proc.runCommand(null, ["sh", "-c", script, Proc.dmsBin, pageUrl], (output, exitCode) => {
const token = (output || "").trim();
if (exitCode !== 0 || token === "") {
log.warn("failed to obtain web-player token");
@@ -137,7 +137,7 @@ Singleton {
function _fetchEditorialVideo(key, serial, albumId) {
const url = "https://amp-api.music.apple.com/v1/catalog/us/albums/" + albumId + "?extend=editorialVideo";
Proc.runCommand(null, _curlCmd.concat(["-H", "Authorization: Bearer " + _token, "-H", "Origin: https://music.apple.com", url]), (output, exitCode) => {
Proc.runCommand(null, _fetchCmd.concat(["-H", "Authorization: Bearer " + _token, "-H", "Origin: https://music.apple.com", url]), (output, exitCode) => {
if (serial !== _serial)
return;
if (exitCode !== 0) {
@@ -163,7 +163,7 @@ Singleton {
}
function _fetchMaster(key, serial, m3u8Url) {
Proc.runCommand(null, _curlCmd.concat([m3u8Url]), (output, exitCode) => {
Proc.runCommand(null, _fetchCmd.concat([m3u8Url]), (output, exitCode) => {
if (serial !== _serial)
return;
if (exitCode !== 0) {
@@ -221,7 +221,7 @@ Singleton {
// The rendition playlist is BYTERANGE segments over one progressive mp4 (EXT-X-MAP).
function _fetchVariant(key, serial, variantUrl) {
Proc.runCommand(null, _curlCmd.concat([variantUrl]), (output, exitCode) => {
Proc.runCommand(null, _fetchCmd.concat([variantUrl]), (output, exitCode) => {
if (serial !== _serial)
return;
if (exitCode !== 0) {
@@ -245,8 +245,8 @@ Singleton {
// Per-process temp then atomic rename, so a concurrent download for the same
// album (rapid track flip-flop) can't interleave writes into one file; the
// temp is removed on failure rather than stranded as a .part.
const script = 'mkdir -p "${1%/*}" && { test -s "$1" || { t="$1.$$.part"; curl -sSf --connect-timeout 5 --max-time 60 -o "$t" "$2" && mv -f "$t" "$1" || { rm -f "$t"; exit 1; }; }; }';
Proc.runCommand(null, ["sh", "-c", script, "sh", path, url], (output, exitCode) => {
const script = 'mkdir -p "${1%/*}" && { test -s "$1" || { t="$1.$$.part"; "$0" dl --connect-timeout 5 --timeout 60 -o "$t" "$2" >/dev/null && mv -f "$t" "$1" || { rm -f "$t"; exit 1; }; }; }';
Proc.runCommand(null, ["sh", "-c", script, Proc.dmsBin, path, url], (output, exitCode) => {
if (serial !== _serial)
return;
if (exitCode !== 0) {
+28 -13
View File
@@ -17,7 +17,8 @@ Singleton {
readonly property var _artHashDenylist: ["764a730860c5b8a7bbee690ee5a443672ae37dc8"]
function djb2Hash(str) {
if (!str) return "";
if (!str)
return "";
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash) + str.charCodeAt(i);
@@ -27,7 +28,8 @@ Singleton {
}
function _directArtworkUrl(player) {
if (!player) return "";
if (!player)
return "";
let artUrl = player.trackArtUrl || "";
if (artUrl !== "") {
@@ -36,7 +38,8 @@ Singleton {
if (player.metadata && player.metadata["mpris:artUrl"]) {
artUrl = player.metadata["mpris:artUrl"].toString();
if (artUrl !== "") return artUrl;
if (artUrl !== "")
return artUrl;
}
// YouTube publishes no artUrl; derive the thumbnail from the video id.
@@ -102,7 +105,7 @@ Singleton {
_commit(localFileUrl, artKey, targetUrl);
loading = false;
} else {
const dlCmd = "mkdir -p \"$(dirname \"$1\")\" && curl -f -s -L -o \"$1\" \"$2\" && mv \"$1\" \"$3\" || { rm -f \"$1\"; exit 1; }";
const dlCmd = "\"$0\" dl -o \"$1\" \"$2\" >/dev/null && mv \"$1\" \"$3\" || { rm -f \"$1\"; exit 1; }";
// YouTube: try the 16:9 maxres thumbnail before falling back.
if (targetUrl.includes("img.youtube.com/vi/")) {
@@ -111,7 +114,7 @@ Singleton {
const mqUrl = "https://img.youtube.com/vi/" + videoId + "/mqdefault.jpg";
const tmpPath = filePath + ".tmp";
Proc.runCommand(null, ["sh", "-c", dlCmd, "sh", tmpPath, maxresUrl, filePath], (maxOutput, maxExitCode) => {
Proc.runCommand(null, ["sh", "-c", dlCmd, Proc.dmsBin, tmpPath, maxresUrl, filePath], (maxOutput, maxExitCode) => {
if (_lastArtUrl !== targetUrl || _requestSerial !== requestSerial)
return;
@@ -119,7 +122,7 @@ Singleton {
_commit(localFileUrl, artKey, targetUrl);
loading = false;
} else {
Proc.runCommand(null, ["sh", "-c", dlCmd, "sh", tmpPath, mqUrl, filePath], (mqOutput, mqExitCode) => {
Proc.runCommand(null, ["sh", "-c", dlCmd, Proc.dmsBin, tmpPath, mqUrl, filePath], (mqOutput, mqExitCode) => {
if (_lastArtUrl !== targetUrl || _requestSerial !== requestSerial)
return;
@@ -130,7 +133,7 @@ Singleton {
}, 50, 15000);
} else {
const tmpPath = filePath + ".tmp";
Proc.runCommand(null, ["sh", "-c", dlCmd, "sh", tmpPath, targetUrl, filePath], (dlOutput, dlExitCode) => {
Proc.runCommand(null, ["sh", "-c", dlCmd, Proc.dmsBin, tmpPath, targetUrl, filePath], (dlOutput, dlExitCode) => {
if (_lastArtUrl !== targetUrl || _requestSerial !== requestSerial)
return;
@@ -194,12 +197,24 @@ Singleton {
required property MprisPlayer modelData
target: modelData
ignoreUnknownSignals: true
function onIsPlayingChanged() { root._updateArtUrl(); }
function onTrackTitleChanged() { root._updateArtUrl(); }
function onTrackArtistChanged() { root._updateArtUrl(); }
function onTrackAlbumChanged() { root._updateArtUrl(); }
function onTrackArtUrlChanged() { root._updateArtUrl(); }
function onMetadataChanged() { root._updateArtUrl(); }
function onIsPlayingChanged() {
root._updateArtUrl();
}
function onTrackTitleChanged() {
root._updateArtUrl();
}
function onTrackArtistChanged() {
root._updateArtUrl();
}
function onTrackAlbumChanged() {
root._updateArtUrl();
}
function onTrackArtUrlChanged() {
root._updateArtUrl();
}
function onMetadataChanged() {
root._updateArtUrl();
}
}
}
+7 -8
View File
@@ -47,7 +47,7 @@ Singleton {
// ionice is util-linux only; the BSDs get plain nice
readonly property var lowPriorityCmd: Qt.platform.os === "linux" ? ["nice", "-n", "19", "ionice", "-c3"] : ["nice", "-n", "19"]
readonly property var curlBaseCmd: ["curl", "-sS", "--fail", "--connect-timeout", "3", "--max-time", "6", "--limit-rate", "100k", "--compressed"]
readonly property var fetchCmd: [Proc.dmsBin, "dl", "--connect-timeout", "8", "--timeout", "20"]
property var weatherIcons: ({
"0": "clear_day",
@@ -564,7 +564,7 @@ Singleton {
}
function getLocationFromCity(city) {
cityGeocodeFetcher.command = lowPriorityCmd.concat(curlBaseCmd).concat([getGeocodingUrl(city)]);
cityGeocodeFetcher.command = lowPriorityCmd.concat(fetchCmd, [getGeocodingUrl(city)]);
cityGeocodeFetcher.running = true;
}
@@ -610,21 +610,21 @@ Singleton {
function tryNominatim(lat, lon, reqId) {
const url = "https://nominatim.openstreetmap.org/reverse?lat=" + lat + "&lon=" + lon + "&format=json&addressdetails=1&accept-language=en";
nominatimFetcher.command = lowPriorityCmd.concat(curlBaseCmd).concat(["-H", "User-Agent: DankMaterialShell Weather Widget", url]);
nominatimFetcher.command = lowPriorityCmd.concat(fetchCmd, ["--user-agent", "DankMaterialShell Weather Widget", url]);
nominatimFetcher.reqId = reqId;
nominatimFetcher.running = true;
}
function tryPhoton(lat, lon, reqId) {
const url = "https://photon.komoot.io/reverse?lat=" + lat + "&lon=" + lon + "&lang=en";
photonFetcher.command = lowPriorityCmd.concat(curlBaseCmd).concat([url]);
photonFetcher.command = lowPriorityCmd.concat(fetchCmd, [url]);
photonFetcher.reqId = reqId;
photonFetcher.running = true;
}
function tryBigDataCloud(lat, lon, reqId) {
const url = "https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=" + lat + "&longitude=" + lon + "&localityLanguage=zh";
bigDataCloudFetcher.command = lowPriorityCmd.concat(curlBaseCmd).concat([url]);
bigDataCloudFetcher.command = lowPriorityCmd.concat(fetchCmd, [url]);
bigDataCloudFetcher.reqId = reqId;
bigDataCloudFetcher.running = true;
}
@@ -659,8 +659,7 @@ Singleton {
root.lastFetchTime = now;
root.weather.loading = true;
const weatherCmd = lowPriorityCmd.concat(["curl", "-sS", "--fail", "--connect-timeout", "3", "--max-time", "6", "--limit-rate", "150k", "--compressed"]);
weatherFetcher.command = weatherCmd.concat([apiUrl]);
weatherFetcher.command = lowPriorityCmd.concat(root.fetchCmd, [apiUrl]);
weatherFetcher.running = true;
}
@@ -817,7 +816,7 @@ Singleton {
Process {
id: ipLocationFetcher
running: false
command: lowPriorityCmd.concat(curlBaseCmd).concat(["http://ip-api.com/json/"])
command: lowPriorityCmd.concat(fetchCmd, ["http://ip-api.com/json/"])
stdout: StdioCollector {
onStreamFinished: {