and the files have not changed causing the signature of the second file to arrive after the delta for the first file is transmitted
1276 lines
37 KiB
Go
1276 lines
37 KiB
Go
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
|
|
|
package transfer
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/zlib"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"golang.org/x/exp/constraints"
|
|
"golang.org/x/exp/slices"
|
|
|
|
"kitty"
|
|
"kitty/tools/cli/markup"
|
|
"kitty/tools/rsync"
|
|
"kitty/tools/tui"
|
|
"kitty/tools/tui/loop"
|
|
"kitty/tools/utils"
|
|
"kitty/tools/utils/humanize"
|
|
"kitty/tools/wcswidth"
|
|
)
|
|
|
|
var _ = fmt.Print
|
|
|
|
type FileState int
|
|
|
|
const (
|
|
WAITING_FOR_START FileState = iota
|
|
WAITING_FOR_DATA
|
|
TRANSMITTING
|
|
FINISHED
|
|
ACKNOWLEDGED
|
|
)
|
|
|
|
type FileHash struct{ dev, inode uint64 }
|
|
|
|
type Compressor interface {
|
|
Compress(data []byte) []byte
|
|
Flush() []byte
|
|
}
|
|
|
|
type IdentityCompressor struct{}
|
|
|
|
func (self *IdentityCompressor) Compress(data []byte) []byte { return data }
|
|
func (self *IdentityCompressor) Flush() []byte { return nil }
|
|
|
|
type ZlibCompressor struct {
|
|
b bytes.Buffer
|
|
w zlib.Writer
|
|
}
|
|
|
|
func NewZlibCompressor() *ZlibCompressor {
|
|
ans := ZlibCompressor{}
|
|
ans.b.Grow(4096)
|
|
ans.w = *zlib.NewWriter(&ans.b)
|
|
return &ans
|
|
}
|
|
|
|
func (self *ZlibCompressor) Compress(data []byte) []byte {
|
|
_, err := self.w.Write(data)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
defer self.b.Reset()
|
|
return utils.UnsafeStringToBytes(self.b.String())
|
|
}
|
|
|
|
func (self *ZlibCompressor) Flush() []byte {
|
|
self.w.Close()
|
|
return self.b.Bytes()
|
|
}
|
|
|
|
type File struct {
|
|
file_hash FileHash
|
|
ttype TransmissionType
|
|
compression Compression
|
|
compressor Compressor
|
|
file_type FileType
|
|
file_id, hard_link_target string
|
|
local_path, symbolic_link_target, expanded_local_path string
|
|
stat_result fs.FileInfo
|
|
state FileState
|
|
display_name string
|
|
mtime time.Time
|
|
file_size, bytes_to_transmit int64
|
|
permissions fs.FileMode
|
|
remote_path string
|
|
rsync_capable, compression_capable bool
|
|
remote_final_path string
|
|
remote_initial_size int64
|
|
err_msg string
|
|
actual_file *os.File
|
|
transmitted_bytes, reported_progress int64
|
|
transmit_started_at, transmit_ended_at, done_at time.Time
|
|
differ *rsync.Differ
|
|
delta_loader func() error
|
|
deltabuf *bytes.Buffer
|
|
}
|
|
|
|
func get_remote_path(local_path string, remote_base string) string {
|
|
if remote_base == "" {
|
|
return filepath.ToSlash(local_path)
|
|
}
|
|
if strings.HasSuffix(remote_base, "/") {
|
|
return filepath.Join(remote_base, filepath.Base(local_path))
|
|
}
|
|
return remote_base
|
|
}
|
|
|
|
func NewFile(opts *Options, local_path, expanded_local_path string, file_id int, stat_result fs.FileInfo, remote_base string, file_type FileType) *File {
|
|
stat, ok := stat_result.Sys().(*syscall.Stat_t)
|
|
if !ok {
|
|
panic("This platform does not support getting file identities from stat results")
|
|
}
|
|
ans := File{
|
|
local_path: local_path, expanded_local_path: expanded_local_path, file_id: fmt.Sprintf("%x", file_id),
|
|
stat_result: stat_result, file_type: file_type, display_name: wcswidth.StripEscapeCodes(local_path),
|
|
file_hash: FileHash{uint64(stat.Dev), stat.Ino}, mtime: stat_result.ModTime(),
|
|
file_size: stat_result.Size(), bytes_to_transmit: stat_result.Size(),
|
|
permissions: stat_result.Mode().Perm(), remote_path: filepath.ToSlash(get_remote_path(local_path, remote_base)),
|
|
rsync_capable: file_type == FileType_regular && stat_result.Size() > 4096,
|
|
compression_capable: file_type == FileType_regular && stat_result.Size() > 4096 && should_be_compressed(expanded_local_path, opts.Compress),
|
|
remote_initial_size: -1,
|
|
}
|
|
return &ans
|
|
}
|
|
|
|
func process(opts *Options, paths []string, remote_base string, counter *int) (ans []*File, err error) {
|
|
for _, x := range paths {
|
|
expanded := expand_home(x)
|
|
s, err := os.Lstat(expanded)
|
|
if err != nil {
|
|
return ans, fmt.Errorf("Failed to stat %s with error: %w", x, err)
|
|
}
|
|
if s.IsDir() {
|
|
*counter += 1
|
|
ans = append(ans, NewFile(opts, x, expanded, *counter, s, remote_base, FileType_directory))
|
|
new_remote_base := remote_base
|
|
if new_remote_base != "" {
|
|
new_remote_base = strings.TrimRight(new_remote_base, "/") + "/" + filepath.Base(x) + "/"
|
|
} else {
|
|
new_remote_base = strings.TrimRight(filepath.ToSlash(x), "/") + "/"
|
|
}
|
|
contents, err := os.ReadDir(expanded)
|
|
if err != nil {
|
|
return ans, fmt.Errorf("Failed to read the directory %s with error: %w", x, err)
|
|
}
|
|
new_paths := make([]string, len(contents))
|
|
for i, y := range contents {
|
|
new_paths[i] = filepath.Join(x, y.Name())
|
|
}
|
|
new_ans, err := process(opts, new_paths, new_remote_base, counter)
|
|
if err != nil {
|
|
return ans, err
|
|
}
|
|
ans = append(ans, new_ans...)
|
|
} else if s.Mode()&fs.ModeSymlink == fs.ModeSymlink {
|
|
*counter += 1
|
|
ans = append(ans, NewFile(opts, x, expanded, *counter, s, remote_base, FileType_symlink))
|
|
} else if s.Mode().IsRegular() {
|
|
*counter += 1
|
|
ans = append(ans, NewFile(opts, x, expanded, *counter, s, remote_base, FileType_regular))
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func process_mirrored_files(opts *Options, args []string) (ans []*File, err error) {
|
|
paths := utils.Map(func(x string) string { return abspath(expand_home(x)) }, args)
|
|
home := strings.TrimRight(home_path(), string(filepath.Separator)) + string(filepath.Separator)
|
|
paths = utils.Map(func(path string) string {
|
|
if strings.HasPrefix(path, home) {
|
|
r, _ := filepath.Rel(home, path)
|
|
return filepath.Join("~", r)
|
|
}
|
|
return path
|
|
}, paths)
|
|
counter := 0
|
|
return process(opts, paths, "", &counter)
|
|
}
|
|
|
|
func process_normal_files(opts *Options, args []string) (ans []*File, err error) {
|
|
if len(args) < 2 {
|
|
return ans, fmt.Errorf("Must specify at least one local path and one remote path")
|
|
}
|
|
args = slices.Clone(args)
|
|
remote_base := filepath.ToSlash(args[len(args)-1])
|
|
args = args[:len(args)-1]
|
|
if len(args) > 1 && !strings.HasSuffix(remote_base, "/") {
|
|
remote_base += "/"
|
|
}
|
|
paths := utils.Map(func(x string) string { return abspath(expand_home(x)) }, args)
|
|
counter := 0
|
|
return process(opts, paths, remote_base, &counter)
|
|
}
|
|
|
|
func files_for_send(opts *Options, args []string) (files []*File, err error) {
|
|
if opts.Mode == "mirror" {
|
|
files, err = process_mirrored_files(opts, args)
|
|
} else {
|
|
files, err = process_normal_files(opts, args)
|
|
}
|
|
if err != nil {
|
|
return files, err
|
|
}
|
|
groups := make(map[FileHash][]*File, len(files))
|
|
|
|
// detect hard links
|
|
for _, f := range files {
|
|
groups[f.file_hash] = append(groups[f.file_hash], f)
|
|
}
|
|
for _, group := range groups {
|
|
if len(group) > 1 {
|
|
for _, lf := range group[1:] {
|
|
lf.file_type = FileType_link
|
|
lf.hard_link_target = "fid:" + group[0].file_id
|
|
}
|
|
}
|
|
}
|
|
|
|
remove := make([]int, 0, len(files))
|
|
// detect symlinks to other transferred files
|
|
for i, f := range files {
|
|
if f.file_type == FileType_symlink {
|
|
link_dest, err := os.Readlink(f.expanded_local_path)
|
|
if err != nil {
|
|
remove = append(remove, i)
|
|
continue
|
|
}
|
|
f.symbolic_link_target = "path:" + link_dest
|
|
is_abs := filepath.IsAbs(link_dest)
|
|
q := link_dest
|
|
if !is_abs {
|
|
q = filepath.Join(filepath.Dir(f.expanded_local_path), link_dest)
|
|
}
|
|
st, err := os.Stat(q)
|
|
if err == nil {
|
|
stat, ok := st.Sys().(*syscall.Stat_t)
|
|
if ok {
|
|
fh := FileHash{uint64(stat.Dev), stat.Ino}
|
|
gr, found := groups[fh]
|
|
if found {
|
|
g := utils.Filter(gr, func(x *File) bool {
|
|
return os.SameFile(x.stat_result, st)
|
|
})
|
|
if len(g) > 0 {
|
|
f.symbolic_link_target = "fid"
|
|
if is_abs {
|
|
f.symbolic_link_target = "fid_abs"
|
|
}
|
|
f.symbolic_link_target += ":" + g[0].file_id
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if len(remove) > 0 {
|
|
for _, idx := range utils.Reverse(remove) {
|
|
files[idx] = nil
|
|
files = slices.Delete(files, idx, idx+1)
|
|
}
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
type SendState int
|
|
|
|
const (
|
|
SEND_WAITING_FOR_PERMISSION SendState = iota
|
|
SEND_PERMISSION_GRANTED
|
|
SEND_PERMISSION_DENIED
|
|
SEND_CANCELED
|
|
)
|
|
|
|
type Transfer struct {
|
|
amt int64
|
|
at time.Time
|
|
}
|
|
|
|
func (self *Transfer) is_too_old(now time.Time) bool {
|
|
return now.Sub(self.at) > 30*time.Second
|
|
}
|
|
|
|
type ProgressTracker struct {
|
|
total_size_of_all_files, total_bytes_to_transfer int64
|
|
active_file *File
|
|
total_transferred int64
|
|
transfers []*Transfer
|
|
transfered_stats_amt int64
|
|
transfered_stats_interval time.Duration
|
|
started_at time.Time
|
|
signature_bytes int
|
|
total_reported_progress int64
|
|
}
|
|
|
|
func (self *ProgressTracker) change_active_file(nf *File) {
|
|
now := time.Now()
|
|
self.active_file = nf
|
|
nf.transmit_started_at = now
|
|
}
|
|
|
|
func (self *ProgressTracker) start_transfer() {
|
|
t := Transfer{at: time.Now()}
|
|
self.transfers = append(self.transfers, &t)
|
|
self.started_at = t.at
|
|
}
|
|
|
|
func (self *ProgressTracker) on_transmit(amt int64, active_file *File) {
|
|
active_file.transmitted_bytes += amt
|
|
self.total_transferred += amt
|
|
now := time.Now()
|
|
self.transfers = append(self.transfers, &Transfer{amt: amt, at: now})
|
|
for len(self.transfers) > 2 && self.transfers[0].is_too_old(now) {
|
|
self.transfers = self.transfers[1:]
|
|
}
|
|
self.transfered_stats_interval = now.Sub(self.transfers[0].at)
|
|
self.transfered_stats_amt = 0
|
|
for _, t := range self.transfers {
|
|
self.transfered_stats_amt += t.amt
|
|
}
|
|
}
|
|
|
|
func (self *ProgressTracker) on_file_progress(af *File, delta int64) {
|
|
if delta > 0 {
|
|
self.total_reported_progress += delta
|
|
}
|
|
}
|
|
|
|
func (self *ProgressTracker) on_file_done(af *File) {
|
|
af.done_at = time.Now()
|
|
}
|
|
|
|
type SendManager struct {
|
|
request_id string
|
|
state SendState
|
|
files []*File
|
|
bypass string
|
|
use_rsync bool
|
|
file_progress func(*File, int)
|
|
file_done func(*File)
|
|
fid_map map[string]*File
|
|
all_acknowledged, all_started, has_transmitting, has_rsync bool
|
|
active_idx int
|
|
prefix, suffix string
|
|
last_progress_file *File
|
|
progress_tracker ProgressTracker
|
|
current_chunk_uncompressed_sz int64
|
|
current_chunk_write_id loop.IdType
|
|
current_chunk_for_file_id string
|
|
}
|
|
|
|
func (self *SendManager) start_transfer() string {
|
|
return FileTransmissionCommand{Action: Action_send, Bypass: self.bypass}.Serialize()
|
|
}
|
|
|
|
func (self *SendManager) initialize() {
|
|
if self.bypass != "" {
|
|
q, err := encode_bypass(self.request_id, self.bypass)
|
|
if err == nil {
|
|
self.bypass = q
|
|
} else {
|
|
fmt.Fprintln(os.Stderr, "Ignoring password because of error:", err)
|
|
}
|
|
|
|
}
|
|
self.fid_map = make(map[string]*File, len(self.files))
|
|
for _, f := range self.files {
|
|
self.fid_map[f.file_id] = f
|
|
}
|
|
self.active_idx = -1
|
|
self.current_chunk_uncompressed_sz = -1
|
|
self.current_chunk_for_file_id = ""
|
|
self.prefix = fmt.Sprintf("\x1b]%d;id=%s;", kitty.FileTransferCode, self.request_id)
|
|
self.suffix = "\x1b\\"
|
|
for _, f := range self.files {
|
|
if f.file_size > 0 {
|
|
self.progress_tracker.total_size_of_all_files += f.file_size
|
|
}
|
|
}
|
|
self.progress_tracker.total_bytes_to_transfer = self.progress_tracker.total_size_of_all_files
|
|
}
|
|
|
|
type SendHandler struct {
|
|
manager *SendManager
|
|
opts *Options
|
|
files []*File
|
|
lp *loop.Loop
|
|
ctx *markup.Context
|
|
transmit_started, file_metadata_sent bool
|
|
quit_after_write_code int
|
|
finish_cmd_write_id loop.IdType
|
|
check_paths_printed bool
|
|
transfer_finish_sent bool
|
|
max_name_length int
|
|
progress_drawn bool
|
|
failed_files, done_files []*File
|
|
done_file_ids *utils.Set[string]
|
|
transmit_ok_checked bool
|
|
progress_update_timer loop.IdType
|
|
spinner *tui.Spinner
|
|
}
|
|
|
|
func safe_divide[A constraints.Integer | constraints.Float, B constraints.Integer | constraints.Float](a A, b B) float64 {
|
|
if b == 0 {
|
|
return 0
|
|
}
|
|
return float64(a) / float64(b)
|
|
}
|
|
|
|
type Progress struct {
|
|
spinner_char string
|
|
bytes_so_far int64
|
|
total_bytes int64
|
|
secs_so_far float64
|
|
bytes_per_sec float64
|
|
is_complete bool
|
|
max_path_length int
|
|
}
|
|
|
|
func reduce_to_single_grapheme(text string) string {
|
|
limit := utf8.RuneCountInString(text)
|
|
if limit < 2 {
|
|
return text
|
|
}
|
|
for x := 1; x < limit; x++ {
|
|
tt, w := wcswidth.TruncateToVisualLengthWithWidth(text, x)
|
|
if w <= x {
|
|
return tt
|
|
}
|
|
}
|
|
return text
|
|
}
|
|
|
|
func render_path_in_width(path string, width int) string {
|
|
path = filepath.ToSlash(path)
|
|
if wcswidth.Stringwidth(path) <= width {
|
|
return path
|
|
}
|
|
parts := strings.Split(path, string(filepath.Separator))
|
|
reduced := strings.Join(utils.Map(reduce_to_single_grapheme, parts[:len(parts)-1]), string(filepath.Separator))
|
|
path = filepath.Join(reduced, parts[len(parts)-1])
|
|
if wcswidth.Stringwidth(path) <= width {
|
|
return path
|
|
}
|
|
return wcswidth.TruncateToVisualLength(path, width-1) + `…`
|
|
}
|
|
|
|
func ljust(text string, width int) string {
|
|
if w := wcswidth.Stringwidth(text); w < width {
|
|
text += strings.Repeat(` `, (width - w))
|
|
}
|
|
return text
|
|
}
|
|
|
|
func rjust(text string, width int) string {
|
|
if w := wcswidth.Stringwidth(text); w < width {
|
|
text = strings.Repeat(` `, (width-w)) + text
|
|
}
|
|
return text
|
|
}
|
|
|
|
func render_progress_in_width(path string, p Progress, width int, ctx *markup.Context) string {
|
|
unit_style := ctx.Dim(`|`)
|
|
sep, trail, _ := strings.Cut(unit_style, "|")
|
|
var ratio, rate, eta string
|
|
if p.is_complete || p.bytes_so_far >= p.total_bytes {
|
|
ratio = humanize.Size(uint64(p.total_bytes), humanize.SizeOptions{Separator: sep})
|
|
rate = humanize.Size(uint64(safe_divide(float64(p.total_bytes), p.secs_so_far)), humanize.SizeOptions{Separator: sep}) + `/s`
|
|
eta = ctx.Green(humanize.ShortDuration(time.Duration(float64(time.Second) * p.secs_so_far)))
|
|
} else {
|
|
tb := humanize.Size(p.total_bytes)
|
|
sval, _, _ := strings.Cut(tb, " ")
|
|
val, _ := strconv.ParseFloat(sval, 64)
|
|
ratio = humanize.FormatNumber(val*safe_divide(p.bytes_so_far, p.total_bytes)) + `/` + strings.ReplaceAll(tb, ` `, sep)
|
|
rate = humanize.Size(p.bytes_per_sec, humanize.SizeOptions{Separator: sep}) + `/s`
|
|
bytes_left := p.total_bytes - p.bytes_so_far
|
|
eta_seconds := safe_divide(bytes_left, p.bytes_per_sec)
|
|
eta = humanize.ShortDuration(time.Duration(float64(time.Second) * eta_seconds))
|
|
}
|
|
lft := p.spinner_char + ` `
|
|
max_space_for_path := width/2 - wcswidth.Stringwidth(lft)
|
|
max_path_length := 80
|
|
w := utils.Min(max_path_length, max_space_for_path)
|
|
prefix := lft + render_path_in_width(path, w)
|
|
w += wcswidth.Stringwidth(lft)
|
|
prefix = ljust(prefix, w)
|
|
q := ratio + trail + ctx.Yellow(" @ ") + rate + trail
|
|
q = rjust(q, 25) + ` `
|
|
eta = ` ` + eta
|
|
if extra := width - w - wcswidth.Stringwidth(q) - wcswidth.Stringwidth(eta); extra > 4 {
|
|
q += tui.RenderProgressBar(safe_divide(p.bytes_so_far, p.total_bytes), extra) + eta
|
|
} else {
|
|
q += strings.TrimSpace(eta)
|
|
}
|
|
return prefix + q
|
|
}
|
|
|
|
func (self *SendHandler) render_progress(name string, p Progress) {
|
|
if p.spinner_char == "" {
|
|
p.spinner_char = " "
|
|
}
|
|
if p.is_complete {
|
|
p.bytes_so_far = p.total_bytes
|
|
}
|
|
p.max_path_length = self.max_name_length
|
|
sz, _ := self.lp.ScreenSize()
|
|
self.lp.QueueWriteString(render_progress_in_width(name, p, int(sz.WidthCells), self.ctx))
|
|
}
|
|
|
|
func (self *SendHandler) draw_progress() {
|
|
self.lp.AllowLineWrapping(false)
|
|
defer self.lp.AllowLineWrapping(true)
|
|
var sc string
|
|
for _, df := range self.done_files {
|
|
sc = self.ctx.Green(`✔`)
|
|
if df.err_msg != "" {
|
|
sc = self.ctx.Err(`✘`)
|
|
}
|
|
if df.file_type == FileType_regular {
|
|
self.draw_progress_for_current_file(df, sc, true)
|
|
} else {
|
|
self.lp.QueueWriteString(sc + ` ` + df.display_name + ` ` + self.ctx.Dim(self.ctx.Italic(df.file_type.String())))
|
|
}
|
|
self.lp.Println()
|
|
self.done_file_ids.Add(df.file_id)
|
|
}
|
|
self.done_files = nil
|
|
is_complete := self.quit_after_write_code > -1
|
|
if is_complete {
|
|
sc = self.ctx.Green(`✔`)
|
|
if self.quit_after_write_code != 0 {
|
|
sc = self.ctx.Err(`✘`)
|
|
}
|
|
} else {
|
|
sc = self.spinner.Tick()
|
|
}
|
|
now := time.Now()
|
|
if is_complete {
|
|
sz, _ := self.lp.ScreenSize()
|
|
self.lp.QueueWriteString(tui.RepeatChar(`─`, int(sz.WidthCells)))
|
|
} else {
|
|
af := self.manager.last_progress_file
|
|
if af == nil || self.done_file_ids.Has(af.file_id) {
|
|
if !self.manager.has_transmitting && self.done_file_ids.Len() == 0 {
|
|
if self.manager.has_rsync {
|
|
self.lp.QueueWriteString(sc + ` Transferring rsync signatures...`)
|
|
} else {
|
|
self.lp.QueueWriteString(sc + ` Transferring metadata...`)
|
|
}
|
|
}
|
|
} else {
|
|
self.draw_progress_for_current_file(af, sc, false)
|
|
}
|
|
}
|
|
self.lp.Println()
|
|
if p := self.manager.progress_tracker; p.total_reported_progress > 0 {
|
|
self.render_progress(`Total`, Progress{
|
|
spinner_char: sc, bytes_so_far: p.total_reported_progress, total_bytes: p.total_bytes_to_transfer,
|
|
secs_so_far: now.Sub(p.started_at).Seconds(), is_complete: is_complete,
|
|
bytes_per_sec: safe_divide(p.transfered_stats_amt, p.transfered_stats_interval.Abs().Seconds()),
|
|
})
|
|
} else {
|
|
self.lp.QueueWriteString(`File data transfer has not yet started`)
|
|
}
|
|
self.lp.Println()
|
|
self.schedule_progress_update(self.spinner.Interval())
|
|
self.progress_drawn = true
|
|
}
|
|
|
|
func (self *SendHandler) draw_progress_for_current_file(af *File, spinner_char string, is_complete bool) {
|
|
p := self.manager.progress_tracker
|
|
var secs_so_far time.Duration
|
|
empty := File{}
|
|
if af.done_at == empty.done_at {
|
|
secs_so_far = time.Now().Sub(af.transmit_started_at)
|
|
} else {
|
|
secs_so_far = af.done_at.Sub(af.transmit_started_at)
|
|
}
|
|
self.render_progress(af.display_name, Progress{
|
|
spinner_char: spinner_char, is_complete: is_complete,
|
|
bytes_so_far: af.reported_progress, total_bytes: af.bytes_to_transmit,
|
|
secs_so_far: secs_so_far.Seconds(), bytes_per_sec: safe_divide(p.transfered_stats_amt, p.transfered_stats_interval.Abs().Seconds()),
|
|
})
|
|
}
|
|
|
|
func (self *SendHandler) erase_progress() {
|
|
if self.progress_drawn {
|
|
self.progress_drawn = false
|
|
self.lp.MoveCursorVertically(-2)
|
|
self.lp.QueueWriteString("\r")
|
|
self.lp.ClearToEndOfScreen()
|
|
}
|
|
}
|
|
|
|
func (self *SendHandler) refresh_progress(timer_id loop.IdType) (err error) {
|
|
if !self.transmit_started || self.manager.state == SEND_CANCELED {
|
|
return nil
|
|
}
|
|
if timer_id == self.progress_update_timer {
|
|
self.progress_update_timer = 0
|
|
}
|
|
self.lp.StartAtomicUpdate()
|
|
defer self.lp.EndAtomicUpdate()
|
|
self.erase_progress()
|
|
self.draw_progress()
|
|
return nil
|
|
}
|
|
|
|
func (self *SendHandler) schedule_progress_update(delay time.Duration) {
|
|
if self.progress_update_timer == 0 {
|
|
timer_id, err := self.lp.AddTimer(delay, false, self.refresh_progress)
|
|
if err == nil {
|
|
self.progress_update_timer = timer_id
|
|
}
|
|
}
|
|
}
|
|
|
|
func (self *SendHandler) on_file_progress(f *File, change int) {
|
|
self.schedule_progress_update(100 * time.Millisecond)
|
|
}
|
|
|
|
func (self *SendHandler) on_file_done(f *File) {
|
|
self.done_files = append(self.done_files, f)
|
|
if f.err_msg != "" {
|
|
self.failed_files = append(self.failed_files, f)
|
|
}
|
|
self.refresh_progress(0)
|
|
}
|
|
|
|
func (self *SendHandler) send_payload(payload string) loop.IdType {
|
|
self.lp.QueueWriteString(self.manager.prefix)
|
|
self.lp.QueueWriteString(payload)
|
|
return self.lp.QueueWriteString(self.manager.suffix)
|
|
}
|
|
|
|
func (self *File) metadata_command(use_rsync bool) *FileTransmissionCommand {
|
|
if use_rsync && self.rsync_capable {
|
|
self.ttype = TransmissionType_rsync
|
|
}
|
|
if self.compression_capable {
|
|
self.compression = Compression_zlib
|
|
self.compressor = NewZlibCompressor()
|
|
} else {
|
|
self.compressor = &IdentityCompressor{}
|
|
}
|
|
return &FileTransmissionCommand{
|
|
Action: Action_file, Compression: self.compression, Ftype: self.file_type,
|
|
Name: self.remote_path, Permissions: self.permissions, Mtime: time.Duration(self.mtime.UnixNano()),
|
|
File_id: self.file_id, Ttype: self.ttype,
|
|
}
|
|
}
|
|
|
|
func (self *SendManager) send_file_metadata(send func(string) loop.IdType) {
|
|
for _, f := range self.files {
|
|
ftc := f.metadata_command(self.use_rsync)
|
|
send(ftc.Serialize())
|
|
}
|
|
}
|
|
|
|
func (self *SendHandler) send_file_metadata() {
|
|
if !self.file_metadata_sent {
|
|
self.file_metadata_sent = true
|
|
self.manager.send_file_metadata(self.send_payload)
|
|
}
|
|
}
|
|
|
|
func (self *SendManager) update_collective_statuses() {
|
|
var found_not_started, found_not_done, has_rsync, has_transmitting bool
|
|
for _, f := range self.files {
|
|
if f.state != ACKNOWLEDGED {
|
|
found_not_done = true
|
|
}
|
|
if f.state == WAITING_FOR_START {
|
|
found_not_started = true
|
|
} else if f.state == TRANSMITTING {
|
|
has_transmitting = true
|
|
}
|
|
if f.ttype == TransmissionType_rsync {
|
|
has_rsync = true
|
|
}
|
|
}
|
|
self.all_acknowledged = !found_not_done
|
|
self.all_started = !found_not_started
|
|
self.has_rsync = has_rsync
|
|
self.has_transmitting = has_transmitting
|
|
}
|
|
|
|
func (self *SendManager) on_file_status_update(ftc *FileTransmissionCommand) error {
|
|
file := self.fid_map[ftc.File_id]
|
|
if file == nil {
|
|
return nil
|
|
}
|
|
switch ftc.Status {
|
|
case `STARTED`:
|
|
file.remote_final_path = ftc.Name
|
|
file.remote_initial_size = int64(ftc.Size)
|
|
if file.file_type == FileType_directory {
|
|
file.state = FINISHED
|
|
} else {
|
|
if ftc.Ttype == TransmissionType_rsync {
|
|
file.state = WAITING_FOR_DATA
|
|
} else {
|
|
file.state = TRANSMITTING
|
|
}
|
|
if file.state == WAITING_FOR_DATA {
|
|
file.differ = rsync.NewDiffer()
|
|
}
|
|
self.update_collective_statuses()
|
|
}
|
|
case `PROGRESS`:
|
|
self.last_progress_file = file
|
|
change := int64(ftc.Size) - file.reported_progress
|
|
file.reported_progress = int64(ftc.Size)
|
|
self.progress_tracker.on_file_progress(file, change)
|
|
self.file_progress(file, int(change))
|
|
default:
|
|
if ftc.Name != "" && file.remote_final_path == "" {
|
|
file.remote_final_path = ftc.Name
|
|
}
|
|
file.state = ACKNOWLEDGED
|
|
if ftc.Status == `OK` {
|
|
if ftc.Size > 0 {
|
|
change := int64(ftc.Size) - file.reported_progress
|
|
file.reported_progress = int64(ftc.Size)
|
|
self.progress_tracker.on_file_progress(file, change)
|
|
self.file_progress(file, int(change))
|
|
}
|
|
} else {
|
|
file.err_msg = ftc.Status
|
|
}
|
|
self.progress_tracker.on_file_done(file)
|
|
self.file_done(file)
|
|
if self.active_idx > -1 && file == self.files[self.active_idx] {
|
|
self.active_idx = -1
|
|
}
|
|
self.update_collective_statuses()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (self *File) start_delta_calculation() (err error) {
|
|
self.state = TRANSMITTING
|
|
if self.actual_file == nil {
|
|
self.actual_file, err = os.Open(self.expanded_local_path)
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
self.deltabuf = bytes.NewBuffer(make([]byte, 0, 32+rsync.DataSizeMultiple*self.differ.BlockSize()))
|
|
self.delta_loader = self.differ.CreateDelta(self.actual_file, self.deltabuf)
|
|
return nil
|
|
}
|
|
|
|
func (self *SendManager) on_signature_data_received(ftc *FileTransmissionCommand) error {
|
|
file := self.fid_map[ftc.File_id]
|
|
if file == nil || file.state != WAITING_FOR_DATA {
|
|
return nil
|
|
}
|
|
if file.differ == nil {
|
|
file.differ = rsync.NewDiffer()
|
|
}
|
|
if err := file.differ.AddSignatureData(ftc.Data); err != nil {
|
|
return err
|
|
}
|
|
self.progress_tracker.signature_bytes += len(ftc.Data)
|
|
if ftc.Action == Action_end_data {
|
|
if err := file.differ.FinishSignatureData(); err != nil {
|
|
return err
|
|
}
|
|
return file.start_delta_calculation()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (self *SendManager) on_file_transfer_response(ftc *FileTransmissionCommand) error {
|
|
switch ftc.Action {
|
|
case Action_status:
|
|
if ftc.File_id != "" {
|
|
return self.on_file_status_update(ftc)
|
|
}
|
|
if ftc.Status == "OK" {
|
|
self.state = SEND_PERMISSION_GRANTED
|
|
} else {
|
|
self.state = SEND_PERMISSION_DENIED
|
|
}
|
|
case Action_data, Action_end_data:
|
|
if ftc.File_id != "" {
|
|
return self.on_signature_data_received(ftc)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (self *SendHandler) on_file_transfer_response(ftc *FileTransmissionCommand) error {
|
|
if ftc.Id != self.manager.request_id {
|
|
return nil
|
|
}
|
|
if ftc.Action == Action_status && ftc.Status == "CANCELED" {
|
|
self.lp.Quit(1)
|
|
return nil
|
|
}
|
|
if self.quit_after_write_code > -1 || self.manager.state == SEND_CANCELED {
|
|
return nil
|
|
}
|
|
before := self.manager.state
|
|
err := self.manager.on_file_transfer_response(ftc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if before == SEND_WAITING_FOR_PERMISSION {
|
|
switch self.manager.state {
|
|
case SEND_PERMISSION_DENIED:
|
|
self.lp.Println(self.ctx.Err("Permission denied for this transfer"))
|
|
self.lp.Quit(1)
|
|
return nil
|
|
case SEND_PERMISSION_GRANTED:
|
|
self.lp.Println(self.ctx.Green("Permission granted for this transfer"))
|
|
self.send_file_metadata()
|
|
}
|
|
}
|
|
if !self.transmit_started {
|
|
return self.check_for_transmit_ok()
|
|
}
|
|
if self.manager.all_acknowledged {
|
|
self.transfer_finished()
|
|
} else if ftc.Action == Action_end_data && ftc.File_id != "" {
|
|
return self.transmit_next_chunk()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (self *SendHandler) check_for_transmit_ok() (err error) {
|
|
if self.transmit_ok_checked {
|
|
return self.start_transfer()
|
|
}
|
|
if self.manager.state != SEND_PERMISSION_GRANTED {
|
|
return
|
|
}
|
|
if self.opts.ConfirmPaths {
|
|
if self.manager.all_started {
|
|
self.print_check_paths()
|
|
}
|
|
return
|
|
}
|
|
self.transmit_ok_checked = true
|
|
self.start_transfer()
|
|
return
|
|
}
|
|
|
|
func (self *SendHandler) print_check_paths() {
|
|
if self.check_paths_printed {
|
|
return
|
|
}
|
|
self.check_paths_printed = true
|
|
self.lp.Println(`The following file transfers will be performed. A red destination means an existing file will be overwritten.`)
|
|
for _, df := range self.manager.files {
|
|
fn := df.remote_final_path
|
|
if df.remote_initial_size > -1 {
|
|
fn = self.ctx.Red(fn)
|
|
}
|
|
self.lp.Println(
|
|
self.ctx.Prettify(fmt.Sprintf(":%s:`%s` ", df.file_type.Color(), df.file_type.ShortText())),
|
|
df.display_name, ` → `, fn)
|
|
}
|
|
hsize := humanize.Size(self.manager.progress_tracker.total_bytes_to_transfer)
|
|
if n := len(self.manager.files); n == 1 {
|
|
self.lp.Println(fmt.Sprintf(`Transferring %d file of total size: %s`, n, hsize))
|
|
} else {
|
|
self.lp.Println(fmt.Sprintf(`Transferring %d files of total size: %s`, n, hsize))
|
|
}
|
|
self.print_continue_msg()
|
|
}
|
|
|
|
func (self *SendManager) activate_next_ready_file() *File {
|
|
if self.active_idx > -1 && self.active_idx < len(self.files) {
|
|
self.files[self.active_idx].transmit_ended_at = time.Now()
|
|
}
|
|
for i, f := range self.files {
|
|
if f.state == TRANSMITTING {
|
|
self.active_idx = i
|
|
self.update_collective_statuses()
|
|
self.progress_tracker.change_active_file(f)
|
|
return f
|
|
}
|
|
}
|
|
self.active_idx = -1
|
|
self.update_collective_statuses()
|
|
return nil
|
|
}
|
|
|
|
func (self *SendManager) active_file() *File {
|
|
if self.active_idx > -1 && self.active_idx < len(self.files) {
|
|
return self.files[self.active_idx]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (self *File) next_chunk() (ans string, asz int, err error) {
|
|
const sz = 1024 * 1024
|
|
switch self.file_type {
|
|
case FileType_symlink:
|
|
self.state = FINISHED
|
|
ans, asz = self.symbolic_link_target, len(self.symbolic_link_target)
|
|
return
|
|
case FileType_link:
|
|
self.state = FINISHED
|
|
ans, asz = self.hard_link_target, len(self.hard_link_target)
|
|
return
|
|
}
|
|
is_last := false
|
|
var chunk []byte
|
|
if self.delta_loader != nil {
|
|
for !is_last && self.deltabuf.Len() < sz {
|
|
if err = self.delta_loader(); err != nil {
|
|
if err == io.EOF {
|
|
is_last = true
|
|
} else {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
chunk = slices.Clone(self.deltabuf.Bytes())
|
|
self.deltabuf.Reset()
|
|
} else {
|
|
if self.actual_file == nil {
|
|
self.actual_file, err = os.Open(self.expanded_local_path)
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
chunk = make([]byte, sz)
|
|
var n int
|
|
n, err = self.actual_file.Read(chunk)
|
|
if err != nil && !errors.Is(err, io.EOF) {
|
|
return
|
|
}
|
|
if n <= 0 {
|
|
is_last = true
|
|
} else if pos, _ := self.actual_file.Seek(0, io.SeekCurrent); pos >= self.file_size {
|
|
is_last = true
|
|
}
|
|
chunk = chunk[:n]
|
|
}
|
|
uncompressed_sz := len(chunk)
|
|
cchunk := self.compressor.Compress(chunk)
|
|
if is_last {
|
|
trail := self.compressor.Flush()
|
|
if len(trail) >= 0 {
|
|
cchunk = append(cchunk, trail...)
|
|
}
|
|
self.state = FINISHED
|
|
if self.actual_file != nil {
|
|
err = self.actual_file.Close()
|
|
self.actual_file = nil
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
self.delta_loader = nil
|
|
self.deltabuf = nil
|
|
}
|
|
ans, asz = utils.UnsafeBytesToString(cchunk), uncompressed_sz
|
|
return
|
|
}
|
|
|
|
func (self *SendManager) next_chunks(callback func(string) loop.IdType) error {
|
|
if self.active_file() == nil {
|
|
self.activate_next_ready_file()
|
|
}
|
|
af := self.active_file()
|
|
if af == nil {
|
|
return nil
|
|
}
|
|
chunk := ""
|
|
self.current_chunk_uncompressed_sz = 0
|
|
for af.state != FINISHED && len(chunk) == 0 {
|
|
c, usz, err := af.next_chunk()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
self.current_chunk_uncompressed_sz += int64(usz)
|
|
self.current_chunk_for_file_id = af.file_id
|
|
chunk = c
|
|
}
|
|
is_last := af.state == FINISHED
|
|
if len(chunk) > 0 {
|
|
split_for_transfer(utils.UnsafeStringToBytes(chunk), af.file_id, is_last, func(ftc *FileTransmissionCommand) {
|
|
self.current_chunk_write_id = callback(ftc.Serialize())
|
|
})
|
|
} else if is_last {
|
|
self.current_chunk_write_id = callback(FileTransmissionCommand{Action: Action_end_data, File_id: af.file_id}.Serialize())
|
|
}
|
|
if is_last {
|
|
self.activate_next_ready_file()
|
|
if self.active_file() == nil {
|
|
return nil
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (self *SendHandler) transmit_next_chunk() (err error) {
|
|
found_chunk := false
|
|
for !found_chunk {
|
|
if err = self.manager.next_chunks(func(chunk string) loop.IdType {
|
|
found_chunk = true
|
|
return self.send_payload(chunk)
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if !found_chunk {
|
|
if self.manager.all_acknowledged {
|
|
self.transfer_finished()
|
|
return
|
|
}
|
|
self.manager.update_collective_statuses()
|
|
if !self.manager.has_transmitting {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func (self *SendHandler) start_transfer() (err error) {
|
|
if self.manager.active_file() == nil {
|
|
self.manager.activate_next_ready_file()
|
|
}
|
|
if self.manager.active_file() != nil {
|
|
self.transmit_started = true
|
|
self.manager.progress_tracker.start_transfer()
|
|
if err = self.transmit_next_chunk(); err != nil {
|
|
return
|
|
}
|
|
self.draw_progress()
|
|
}
|
|
return
|
|
}
|
|
|
|
func (self *SendHandler) initialize() error {
|
|
self.manager.initialize()
|
|
self.spinner = tui.NewSpinner("dots")
|
|
self.ctx = markup.New(true)
|
|
self.send_payload(self.manager.start_transfer())
|
|
if self.opts.PermissionsBypass != "" {
|
|
// dont wait for permission, not needed with a bypass and avoids a roundtrip
|
|
self.send_file_metadata()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (self *SendHandler) transfer_finished() {
|
|
if self.transfer_finish_sent {
|
|
return
|
|
}
|
|
self.transfer_finish_sent = true
|
|
self.finish_cmd_write_id = self.send_payload(FileTransmissionCommand{Action: Action_finish}.Serialize())
|
|
}
|
|
|
|
func (self *SendHandler) on_text(text string, from_key_event, in_bracketed_paste bool) error {
|
|
if self.quit_after_write_code > -1 {
|
|
return nil
|
|
}
|
|
if self.check_paths_printed && !self.transmit_started {
|
|
switch strings.ToLower(text) {
|
|
case "y":
|
|
err := self.start_transfer()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if self.manager.all_acknowledged {
|
|
self.refresh_progress(0)
|
|
self.transfer_finished()
|
|
}
|
|
return nil
|
|
case "n":
|
|
self.failed_files = nil
|
|
self.abort_transfer()
|
|
self.lp.Println(`Sending cancel request to terminal`)
|
|
return nil
|
|
}
|
|
self.print_continue_msg()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (self *SendHandler) print_continue_msg() {
|
|
self.lp.Println(
|
|
`Press`, self.ctx.Green(`y`), `to continue or`, self.ctx.BrightRed(`n`), `to abort`)
|
|
}
|
|
|
|
func (self *SendHandler) abort_transfer(delay ...time.Duration) {
|
|
d := 5 * time.Second
|
|
if len(delay) > 0 {
|
|
d = delay[0]
|
|
}
|
|
self.send_payload(FileTransmissionCommand{Action: Action_cancel}.Serialize())
|
|
self.manager.state = SEND_CANCELED
|
|
self.lp.AddTimer(d, false, func(loop.IdType) error {
|
|
self.lp.Quit(1)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (self *SendHandler) on_resize(old_size, new_size loop.ScreenSize) error {
|
|
if self.progress_drawn {
|
|
self.refresh_progress(0)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (self *SendHandler) on_key_event(ev *loop.KeyEvent) error {
|
|
if self.quit_after_write_code > -1 {
|
|
return nil
|
|
}
|
|
if ev.MatchesPressOrRepeat("esc") {
|
|
ev.Handled = true
|
|
if self.check_paths_printed && !self.transmit_started {
|
|
self.failed_files = nil
|
|
self.abort_transfer()
|
|
self.lp.Println(`Sending cancel request to terminal`)
|
|
return nil
|
|
} else {
|
|
self.on_interrupt()
|
|
}
|
|
} else if ev.MatchesPressOrRepeat("ctrl+c") {
|
|
self.on_interrupt()
|
|
ev.Handled = true
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (self *SendHandler) on_writing_finished(msg_id loop.IdType, has_pending_writes bool) (err error) {
|
|
chunk_transmitted := self.manager.current_chunk_uncompressed_sz >= 0 && msg_id == self.manager.current_chunk_write_id
|
|
if chunk_transmitted {
|
|
self.manager.progress_tracker.on_transmit(self.manager.current_chunk_uncompressed_sz, self.manager.fid_map[self.manager.current_chunk_for_file_id])
|
|
self.manager.current_chunk_uncompressed_sz = -1
|
|
self.manager.current_chunk_write_id = 0
|
|
self.manager.current_chunk_for_file_id = ""
|
|
}
|
|
if self.finish_cmd_write_id > 0 && msg_id == self.finish_cmd_write_id {
|
|
if len(self.failed_files) > 0 {
|
|
self.quit_after_write_code = 1
|
|
} else {
|
|
self.quit_after_write_code = 0
|
|
}
|
|
self.refresh_progress(0)
|
|
}
|
|
if self.quit_after_write_code > -1 && !has_pending_writes {
|
|
self.lp.Quit(self.quit_after_write_code)
|
|
return
|
|
}
|
|
if self.manager.state == SEND_PERMISSION_GRANTED && !self.transmit_started {
|
|
return self.check_for_transmit_ok()
|
|
}
|
|
if chunk_transmitted {
|
|
self.refresh_progress(0)
|
|
return self.transmit_next_chunk()
|
|
}
|
|
return
|
|
}
|
|
|
|
func (self *SendHandler) on_interrupt() {
|
|
if self.quit_after_write_code > -1 {
|
|
return
|
|
}
|
|
if self.manager.state == SEND_CANCELED {
|
|
self.lp.Println(`Waiting for canceled acknowledgement from terminal, will abort in a few seconds if no response received`)
|
|
return
|
|
}
|
|
self.lp.Println(self.ctx.BrightRed(`Interrupt requested, cancelling transfer, transferred files are in undefined state`))
|
|
self.abort_transfer()
|
|
}
|
|
|
|
func send_loop(opts *Options, files []*File) (err error, rc int) {
|
|
lp, err := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors)
|
|
if err != nil {
|
|
return err, 1
|
|
}
|
|
|
|
handler := &SendHandler{
|
|
opts: opts, files: files, lp: lp, quit_after_write_code: -1,
|
|
max_name_length: utils.Max(0, utils.Map(func(f *File) int { return wcswidth.Stringwidth(f.display_name) }, files)...),
|
|
progress_drawn: true, done_file_ids: utils.NewSet[string](),
|
|
manager: &SendManager{
|
|
request_id: random_id(), files: files, bypass: opts.PermissionsBypass, use_rsync: opts.TransmitDeltas,
|
|
},
|
|
}
|
|
handler.manager.file_progress = handler.on_file_progress
|
|
handler.manager.file_done = handler.on_file_done
|
|
|
|
lp.OnInitialize = func() (string, error) {
|
|
lp.SetCursorVisible(false)
|
|
return "", handler.initialize()
|
|
}
|
|
lp.OnFinalize = func() string {
|
|
lp.SetCursorVisible(true)
|
|
return ""
|
|
}
|
|
ftc_code := strconv.Itoa(kitty.FileTransferCode)
|
|
lp.OnEscapeCode = func(et loop.EscapeCodeType, payload []byte) error {
|
|
if et == loop.OSC {
|
|
if idx := bytes.IndexByte(payload, ';'); idx > 0 {
|
|
if utils.UnsafeBytesToString(payload[:idx]) == ftc_code {
|
|
ftc, err := NewFileTransmissionCommand(utils.UnsafeBytesToString(payload[idx+1:]))
|
|
if err != nil {
|
|
return fmt.Errorf("Received invalid FileTransmissionCommand from terminal with error: %w", err)
|
|
}
|
|
return handler.on_file_transfer_response(ftc)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
lp.OnText = handler.on_text
|
|
lp.OnKeyEvent = handler.on_key_event
|
|
lp.OnResize = handler.on_resize
|
|
lp.OnWriteComplete = handler.on_writing_finished
|
|
|
|
err = lp.Run()
|
|
if err != nil {
|
|
return err, 1
|
|
}
|
|
if lp.DeathSignalName() != "" {
|
|
lp.KillIfSignalled()
|
|
return
|
|
}
|
|
p := handler.manager.progress_tracker
|
|
if handler.manager.has_rsync && p.total_transferred+int64(p.signature_bytes) > 0 && lp.ExitCode() == 0 {
|
|
var tsf int64
|
|
for _, f := range files {
|
|
if f.ttype == TransmissionType_rsync {
|
|
tsf += f.file_size
|
|
}
|
|
}
|
|
if tsf > 0 {
|
|
print_rsync_stats(tsf, p.total_transferred, int64(p.signature_bytes))
|
|
}
|
|
}
|
|
if len(handler.failed_files) > 0 {
|
|
fmt.Fprintf(os.Stderr, "Transfer of %d out of %d files failed\n", len(handler.failed_files), len(handler.manager.files))
|
|
for _, f := range handler.failed_files {
|
|
fmt.Println(handler.ctx.BrightRed(f.display_name))
|
|
fmt.Println(` `, f.err_msg)
|
|
}
|
|
rc = 1
|
|
}
|
|
if lp.ExitCode() != 0 {
|
|
rc = lp.ExitCode()
|
|
}
|
|
return
|
|
}
|
|
|
|
func send_main(opts *Options, args []string) (err error, rc int) {
|
|
fmt.Println("Scanning files…")
|
|
files, err := files_for_send(opts, args)
|
|
if err != nil {
|
|
return err, 1
|
|
}
|
|
fmt.Printf("Found %d files and directories, requesting transfer permission…", len(files))
|
|
fmt.Println()
|
|
err, rc = send_loop(opts, files)
|
|
|
|
return
|
|
}
|