diff --git a/core/cmd/dms/commands_download.go b/core/cmd/dms/commands_download.go index e9caeb00e..b5b4121cb 100644 --- a/core/cmd/dms/commands_download.go +++ b/core/cmd/dms/commands_download.go @@ -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) diff --git a/core/cmd/dms/commands_features.go b/core/cmd/dms/commands_features.go index 4cacf50b5..ac8aa27e3 100644 --- a/core/cmd/dms/commands_features.go +++ b/core/cmd/dms/commands_features.go @@ -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) } diff --git a/core/internal/distros/base.go b/core/internal/distros/base.go index e61690c71..f8cb3e442 100644 --- a/core/internal/distros/base.go +++ b/core/internal/distros/base.go @@ -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) } diff --git a/core/internal/distros/debian.go b/core/internal/distros/debian.go index d8d432cfa..d790e840e 100644 --- a/core/internal/distros/debian.go +++ b/core/internal/distros/debian.go @@ -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) } diff --git a/core/internal/distros/pgpkey.go b/core/internal/distros/pgpkey.go new file mode 100644 index 000000000..4d026a608 --- /dev/null +++ b/core/internal/distros/pgpkey.go @@ -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 +} diff --git a/core/internal/distros/pgpkey_test.go b/core/internal/distros/pgpkey_test.go new file mode 100644 index 000000000..a8123cb84 --- /dev/null +++ b/core/internal/distros/pgpkey_test.go @@ -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") + } + }) + } +} diff --git a/core/internal/distros/testdata/obs-release.asc b/core/internal/distros/testdata/obs-release.asc new file mode 100644 index 000000000..0a0e2bbb1 --- /dev/null +++ b/core/internal/distros/testdata/obs-release.asc @@ -0,0 +1,41 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=kr/Y +-----END PGP PUBLIC KEY BLOCK----- diff --git a/core/internal/distros/testdata/obs-release.gpg b/core/internal/distros/testdata/obs-release.gpg new file mode 100644 index 000000000..54a6b2a4f Binary files /dev/null and b/core/internal/distros/testdata/obs-release.gpg differ diff --git a/core/internal/netfetch/netfetch.go b/core/internal/netfetch/netfetch.go new file mode 100644 index 000000000..e756bf2c6 --- /dev/null +++ b/core/internal/netfetch/netfetch.go @@ -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 +} diff --git a/core/internal/netfetch/release.go b/core/internal/netfetch/release.go new file mode 100644 index 000000000..5327c7b81 --- /dev/null +++ b/core/internal/netfetch/release.go @@ -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 +} diff --git a/core/internal/version/version.go b/core/internal/version/version.go index 63eed593c..ccc923c3b 100644 --- a/core/internal/version/version.go +++ b/core/internal/version/version.go @@ -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 diff --git a/quickshell/Services/AppleMusicArtService.qml b/quickshell/Services/AppleMusicArtService.qml index 5b9bce43e..1b3587d39 100644 --- a/quickshell/Services/AppleMusicArtService.qml +++ b/quickshell/Services/AppleMusicArtService.qml @@ -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) { diff --git a/quickshell/Services/TrackArtService.qml b/quickshell/Services/TrackArtService.qml index e40d6c5ea..928250564 100644 --- a/quickshell/Services/TrackArtService.qml +++ b/quickshell/Services/TrackArtService.qml @@ -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(); + } } } diff --git a/quickshell/Services/WeatherService.qml b/quickshell/Services/WeatherService.qml index 863add3d6..26d7a955e 100644 --- a/quickshell/Services/WeatherService.qml +++ b/quickshell/Services/WeatherService.qml @@ -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: {