304 lines
7.4 KiB
Go
304 lines
7.4 KiB
Go
//go:build linux || freebsd
|
|
// +build linux freebsd
|
|
|
|
package output
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// WriterConfig holds configuration for the Writer.
|
|
type WriterConfig struct {
|
|
// Output is the destination file path (use "-" for stdout).
|
|
Output string
|
|
|
|
// BufferSize is the size of the write buffer.
|
|
BufferSize int
|
|
|
|
// Atomic enables atomic write via temp file + rename.
|
|
Atomic bool
|
|
|
|
// Resume enables support for resuming interrupted downloads.
|
|
Resume bool
|
|
|
|
// ProgressCallback is called on each write with the current progress.
|
|
ProgressCallback func(current, total int64, speed float64)
|
|
|
|
// Verbose enables verbose logging.
|
|
Verbose bool
|
|
|
|
// CreateDirs creates missing parent directories of Output via os.MkdirAll
|
|
// before opening the file. Ignored when Output is "-" (stdout). The
|
|
// equivalent of curl --create-dirs / wget --directory-prefix.
|
|
CreateDirs bool
|
|
}
|
|
|
|
// DefaultWriterConfig returns a WriterConfig with sensible defaults.
|
|
func DefaultWriterConfig() *WriterConfig {
|
|
return &WriterConfig{
|
|
BufferSize: 32 * 1024, // 32KB
|
|
Atomic: true,
|
|
Resume: false,
|
|
Verbose: false,
|
|
}
|
|
}
|
|
|
|
// Writer is a progress-aware file writer supporting atomic writes and resume.
|
|
type Writer struct {
|
|
config *WriterConfig
|
|
file *os.File
|
|
tempFile *os.File
|
|
written int64
|
|
total int64
|
|
startTime time.Time
|
|
mu sync.Mutex
|
|
callback func(current, total int64, speed float64)
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
// NewWriter creates a new writer
|
|
func NewWriter(cfg *WriterConfig) (*Writer, error) {
|
|
if cfg == nil {
|
|
cfg = DefaultWriterConfig()
|
|
}
|
|
|
|
w := &Writer{
|
|
config: cfg,
|
|
written: 0,
|
|
startTime: time.Now(),
|
|
callback: cfg.ProgressCallback,
|
|
}
|
|
|
|
// Create context for cancellation.
|
|
w.ctx, w.cancel = context.WithCancel(context.Background())
|
|
|
|
// Determine output path
|
|
outputPath := cfg.Output
|
|
if outputPath == "" {
|
|
outputPath = "-" // stdout
|
|
}
|
|
|
|
// Create parent directories on demand (curl --create-dirs / wget
|
|
// --directory-prefix). Skip for stdout where there is no path.
|
|
if cfg.CreateDirs && outputPath != "-" {
|
|
dir := filepath.Dir(outputPath)
|
|
if dir != "" && dir != "." {
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return nil, fmt.Errorf("failed to create output directory: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Atomic write - create temp file
|
|
if cfg.Atomic && outputPath != "-" {
|
|
dir := filepath.Dir(outputPath)
|
|
name := filepath.Base(outputPath)
|
|
tempName := fmt.Sprintf(".%s.tmp.%d", name, time.Now().UnixNano())
|
|
tempPath := filepath.Join(dir, tempName)
|
|
|
|
tempFile, err := os.OpenFile(tempPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create temp file: %w", err)
|
|
}
|
|
|
|
w.tempFile = tempFile
|
|
w.file = tempFile
|
|
} else if outputPath == "-" {
|
|
w.file = os.Stdout
|
|
} else {
|
|
// Check if file exists for resume.
|
|
if cfg.Resume {
|
|
if info, err := os.Stat(outputPath); err == nil {
|
|
w.written = info.Size()
|
|
// Open in append mode
|
|
file, err := os.OpenFile(outputPath, os.O_APPEND|os.O_WRONLY, 0644)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open file for resume: %w", err)
|
|
}
|
|
w.file = file
|
|
} else {
|
|
// File doesn't exist, create new
|
|
file, err := os.Create(outputPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create file: %w", err)
|
|
}
|
|
w.file = file
|
|
}
|
|
} else {
|
|
// Check if file exists
|
|
if _, err := os.Stat(outputPath); err == nil {
|
|
return nil, fmt.Errorf("file already exists: %s", outputPath)
|
|
}
|
|
|
|
file, err := os.Create(outputPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create file: %w", err)
|
|
}
|
|
w.file = file
|
|
}
|
|
}
|
|
|
|
return w, nil
|
|
}
|
|
|
|
// Write implements the io.Writer interface.
|
|
func (w *Writer) Write(p []byte) (int, error) {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
|
|
n, err := w.file.Write(p)
|
|
if err != nil {
|
|
return n, err
|
|
}
|
|
|
|
w.written += int64(n)
|
|
|
|
// Report progress
|
|
if w.callback != nil {
|
|
duration := time.Since(w.startTime)
|
|
speed := float64(w.written) / duration.Seconds()
|
|
w.callback(w.written, w.total, speed)
|
|
}
|
|
|
|
return n, nil
|
|
}
|
|
|
|
// SetTotal sets the expected total size for progress tracking.
|
|
func (w *Writer) SetTotal(total int64) {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
w.total = total
|
|
}
|
|
|
|
// SetProgressCallback sets the callback function for progress reporting.
|
|
func (w *Writer) SetProgressCallback(cb func(current, total int64, speed float64)) {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
w.callback = cb
|
|
}
|
|
|
|
// Written returns the number of bytes written so far.
|
|
func (w *Writer) Written() int64 {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
return w.written
|
|
}
|
|
|
|
// Total returns the expected total size.
|
|
func (w *Writer) Total() int64 {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
return w.total
|
|
}
|
|
|
|
// Close finalizes the write and performs the atomic rename if applicable.
|
|
func (w *Writer) Close() error {
|
|
w.cancel()
|
|
|
|
if w.file == nil {
|
|
return nil
|
|
}
|
|
|
|
// Don't close stdout — it's shared across the process
|
|
if w.file != os.Stdout {
|
|
if err := w.file.Close(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Atomic rename if temp file was used
|
|
if w.tempFile != nil && w.config.Output != "-" {
|
|
tempPath := w.tempFile.Name()
|
|
if err := os.Rename(tempPath, w.config.Output); err != nil {
|
|
if errors.Is(err, syscall.EXDEV) {
|
|
// Cross-filesystem rename is not supported by the OS.
|
|
// Fall back to a copy + remove so the data still reach
|
|
// the destination directory instead of being lost.
|
|
if fallbackErr := copyFileAndRemove(tempPath, w.config.Output); fallbackErr != nil {
|
|
return fmt.Errorf("atomic rename failed across filesystems and fallback copy failed: rename=%v copy=%w", err, fallbackErr)
|
|
}
|
|
return nil
|
|
}
|
|
// Any other error (permission denied, target is a directory,
|
|
// target busy on Windows, etc.) — keep the temp file so the
|
|
// user can recover the partial download manually.
|
|
return fmt.Errorf("failed to rename temp file %q to %q: %w (temp file kept for recovery)", tempPath, w.config.Output, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// copyFileAndRemove copies src to dst (truncating any existing file) and
|
|
// removes src on success. Used as a fallback for os.Rename when the temp
|
|
// file and the destination are on different filesystems.
|
|
func copyFileAndRemove(src, dst string) error {
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
|
|
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := io.Copy(out, in); err != nil {
|
|
out.Close()
|
|
return err
|
|
}
|
|
if err := out.Close(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return os.Remove(src)
|
|
}
|
|
|
|
// Cancel aborts the write and cleans up any temporary files.
|
|
func (w *Writer) Cancel() {
|
|
w.cancel()
|
|
|
|
if w.tempFile != nil {
|
|
// Remove temp file on cancel
|
|
tempPath := w.tempFile.Name()
|
|
w.tempFile.Close()
|
|
os.Remove(tempPath)
|
|
}
|
|
}
|
|
|
|
// GetSpeed returns the average write speed in bytes per second.
|
|
func (w *Writer) GetSpeed() float64 {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
duration := time.Since(w.startTime)
|
|
if duration.Seconds() == 0 {
|
|
return 0
|
|
}
|
|
return float64(w.written) / duration.Seconds()
|
|
}
|
|
|
|
// GetDuration returns the elapsed time since the writer was created.
|
|
func (w *Writer) GetDuration() time.Duration {
|
|
return time.Since(w.startTime)
|
|
}
|
|
|
|
// GetProgress returns the progress as a percentage of the total size.
|
|
func (w *Writer) GetProgress() float64 {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
if w.total == 0 {
|
|
return 0
|
|
}
|
|
return float64(w.written) / float64(w.total) * 100
|
|
}
|