github.com/keybase/client/go@v0.0.0-20240309051027-028f7c731f8b/kbfs/simplefs/downloads.go (about)

     1  // Copyright 2019 Keybase Inc. All rights reserved.
     2  // Use of this source code is governed by a BSD
     3  // license that can be found in the LICENSE file.
     4  
     5  package simplefs
     6  
     7  import (
     8  	"os"
     9  	"path"
    10  	"path/filepath"
    11  	"sort"
    12  	"strconv"
    13  	"sync"
    14  	"syscall"
    15  	"time"
    16  
    17  	"github.com/keybase/client/go/kbfs/libkbfs"
    18  	"github.com/keybase/client/go/libkb"
    19  	"github.com/keybase/client/go/protocol/keybase1"
    20  	"github.com/pkg/errors"
    21  	"golang.org/x/net/context"
    22  )
    23  
    24  const (
    25  	// dlCtxOpID is the display name for the unique operation SimpleFS ID tag.
    26  	dlCtxOpID = "SFSDLID"
    27  )
    28  
    29  // dlCtxTagKey is the type used for unique context tags
    30  type dlCtxTagKey int
    31  
    32  const (
    33  	// dlCtxIDKey is the type of the tag for unique operation IDs.
    34  	dlCtxIDKey dlCtxTagKey = iota
    35  )
    36  
    37  type download struct {
    38  	info         keybase1.DownloadInfo
    39  	safeFilename string
    40  	state        keybase1.DownloadState
    41  	opid         keybase1.OpID
    42  }
    43  
    44  // downloadManager manages "downloads" initiated from outside KBFS. To KBFS,
    45  // this is more like "exporting". Currently this is only used by GUI, so its
    46  // APIs are tailored to the GUI.
    47  //
    48  // We have regular downloads which are tracked in the app visually, and are
    49  // moved into a "Downloads" folder after they're done, and non-regular
    50  // downloads which are for "Save" and "Send to other apps" on mobile. When the
    51  // user chooses to save a photo or a video, or share a file to another app, we
    52  // download to a cache folder and have GUI call some APIs to actually add them
    53  // to the photo library or send to other apps.
    54  type downloadManager struct {
    55  	k         *SimpleFS
    56  	publisher libkbfs.SubscriptionManagerPublisher
    57  
    58  	lock        sync.RWMutex
    59  	cacheDir    string
    60  	downloadDir string
    61  	downloads   map[string]download // download ID -> download
    62  }
    63  
    64  func newDownloadManager(simpleFS *SimpleFS) *downloadManager {
    65  	return &downloadManager{
    66  		k:           simpleFS,
    67  		publisher:   simpleFS.config.SubscriptionManagerPublisher(),
    68  		cacheDir:    simpleFS.config.KbEnv().GetCacheDir(),
    69  		downloadDir: simpleFS.config.KbEnv().GetDownloadsDir(),
    70  		downloads:   make(map[string]download),
    71  	}
    72  }
    73  
    74  func (m *downloadManager) makeContext() (ctx context.Context, cancel func()) {
    75  	return context.WithCancel(libkbfs.CtxWithRandomIDReplayable(context.Background(), dlCtxIDKey, dlCtxOpID, m.k.log))
    76  }
    77  
    78  func (m *downloadManager) getDownload(downloadID string) (download, error) {
    79  	m.lock.RLock()
    80  	defer m.lock.RUnlock()
    81  	d, ok := m.downloads[downloadID]
    82  	if !ok {
    83  		return download{}, errors.New("unknown downloadID")
    84  	}
    85  	return d, nil
    86  }
    87  
    88  func (m *downloadManager) updateDownload(downloadID string, f func(original download) download) (err error) {
    89  	defer m.publisher.PublishChange(keybase1.SubscriptionTopic_DOWNLOAD_STATUS)
    90  	m.lock.Lock()
    91  	defer m.lock.Unlock()
    92  	download, ok := m.downloads[downloadID]
    93  	if !ok {
    94  		return errors.New("unknown downloadID")
    95  	}
    96  	m.downloads[downloadID] = f(download)
    97  	return nil
    98  }
    99  
   100  const monitorDownloadTickerInterval = time.Second
   101  
   102  func (m *downloadManager) monitorDownload(
   103  	ctx context.Context, opid keybase1.OpID, downloadID string,
   104  	done func(error)) {
   105  	ticker := time.NewTicker(monitorDownloadTickerInterval)
   106  	defer ticker.Stop()
   107  	for {
   108  		select {
   109  		case <-ticker.C:
   110  			resp, err := m.k.SimpleFSCheck(ctx, opid)
   111  			switch errors.Cause(err) {
   112  			case nil:
   113  				if err := m.updateDownload(downloadID, func(d download) download {
   114  					d.state.EndEstimate = resp.EndEstimate
   115  					d.state.Progress = float64(
   116  						resp.BytesWritten) / float64(resp.BytesTotal)
   117  					return d
   118  				}); err != nil {
   119  					done(err)
   120  					return
   121  				}
   122  			case errNoResult:
   123  				// This is from simpleFS. Likely download has finished, but
   124  				// wait for ctx.Done().
   125  			default:
   126  				done(err)
   127  				return
   128  			}
   129  		case <-ctx.Done():
   130  			return
   131  		}
   132  	}
   133  }
   134  
   135  func (m *downloadManager) getCacheDir() string {
   136  	m.lock.RLock()
   137  	defer m.lock.RUnlock()
   138  	return m.cacheDir
   139  }
   140  
   141  func (m *downloadManager) getDownloadDir() string {
   142  	m.lock.RLock()
   143  	defer m.lock.RUnlock()
   144  	return m.downloadDir
   145  }
   146  
   147  func (m *downloadManager) getFilenames(
   148  	kbfsPath keybase1.KBFSPath) (filename, safeFilename string) {
   149  	_, filename = path.Split(path.Clean(kbfsPath.Path))
   150  	return filename, libkb.GetSafeFilename(filename)
   151  }
   152  
   153  func (m *downloadManager) getDownloadPath(
   154  	ctx context.Context, filename, downloadID string) (
   155  	downloadPath string, err error) {
   156  	parentDir := filepath.Join(m.getCacheDir(), "simplefsdownload")
   157  	if err = os.MkdirAll(parentDir, 0700); err != nil {
   158  		return "", err
   159  	}
   160  	downloadPath = filepath.Join(parentDir, downloadID+path.Ext(filename))
   161  	return downloadPath, nil
   162  }
   163  
   164  func (m *downloadManager) moveToDownloadFolder(
   165  	ctx context.Context, srcPath string, filename string) (localPath string, err error) {
   166  	// There's no download on iOS; just saving to the photos library and
   167  	// sharing to other apps, both of which are handled in JS after the
   168  	// download (to the cache dir) finishes.
   169  	if libkb.GetPlatformString() == "ios" || libkb.GetPlatformString() == "ipad" {
   170  		return "", errors.New("MoveToDownloadFolder is not supported on iOS")
   171  	}
   172  	parentDir := m.getDownloadDir()
   173  	if err = os.MkdirAll(parentDir, 0700); err != nil {
   174  		return "", err
   175  	}
   176  	filename = limitFilenameLengthForWindowsDownloads(filename)
   177  	destPath, err := libkb.FindFilePathWithNumberSuffix(parentDir, filename, false)
   178  	if err != nil {
   179  		return "", err
   180  	}
   181  
   182  	err = os.Rename(srcPath, destPath)
   183  	switch er := err.(type) {
   184  	case nil:
   185  		return destPath, nil
   186  	case *os.LinkError:
   187  		if er.Err != syscall.EXDEV {
   188  			return "", err
   189  		}
   190  		// Rename failed because dest and src are on different devices. So
   191  		// use SimpleFSMove which copies then deletes.
   192  		opid, err := m.k.SimpleFSMakeOpid(ctx)
   193  		if err != nil {
   194  			return "", err
   195  		}
   196  		err = m.k.SimpleFSMove(ctx, keybase1.SimpleFSMoveArg{
   197  			OpID: opid,
   198  			Src:  keybase1.NewPathWithLocal(srcPath),
   199  			Dest: keybase1.NewPathWithLocal(destPath),
   200  		})
   201  		if err != nil {
   202  			return "", err
   203  		}
   204  		err = m.k.SimpleFSWait(ctx, opid)
   205  		if err != nil {
   206  			return "", err
   207  		}
   208  		return destPath, nil
   209  	default:
   210  		return "", err
   211  	}
   212  }
   213  
   214  func (m *downloadManager) waitForDownload(ctx context.Context,
   215  	downloadID string, downloadPath string, done func(error)) {
   216  	d, err := m.getDownload(downloadID)
   217  	if err != nil {
   218  		done(err)
   219  		return
   220  	}
   221  	err = m.k.SimpleFSWait(ctx, d.opid)
   222  	if err != nil {
   223  		done(err)
   224  		return
   225  	}
   226  
   227  	var localPath string
   228  	if d.info.IsRegularDownload {
   229  		localPath, err = m.moveToDownloadFolder(
   230  			ctx, downloadPath, d.safeFilename)
   231  		if err != nil {
   232  			done(err)
   233  			return
   234  		}
   235  	} else {
   236  		localPath = downloadPath
   237  	}
   238  
   239  	done(m.updateDownload(downloadID, func(d download) download {
   240  		d.state.LocalPath = localPath
   241  		return d
   242  	}))
   243  }
   244  
   245  func (m *downloadManager) startDownload(
   246  	ctx context.Context, arg keybase1.SimpleFSStartDownloadArg) (
   247  	downloadID string, err error) {
   248  	opid, err := m.k.SimpleFSMakeOpid(ctx)
   249  	if err != nil {
   250  		return "", err
   251  	}
   252  	downloadID = strconv.FormatInt(time.Now().UnixNano(), 16)
   253  	filename, safeFilename := m.getFilenames(arg.Path)
   254  	downloadPath, err := m.getDownloadPath(ctx, filename, downloadID)
   255  	if err != nil {
   256  		return "", err
   257  	}
   258  	// TODO for dirs maybe we want zip instead?
   259  	err = m.k.SimpleFSCopyRecursive(ctx, keybase1.SimpleFSCopyRecursiveArg{
   260  		OpID: opid,
   261  		Src:  keybase1.NewPathWithKbfs(arg.Path),
   262  		Dest: keybase1.NewPathWithLocal(downloadPath),
   263  	})
   264  	if err != nil {
   265  		return "", err
   266  	}
   267  
   268  	func() {
   269  		defer m.publisher.PublishChange(keybase1.SubscriptionTopic_DOWNLOAD_STATUS)
   270  		m.lock.Lock()
   271  		defer m.lock.Unlock()
   272  		m.downloads[downloadID] = download{
   273  			info: keybase1.DownloadInfo{
   274  				DownloadID:        downloadID,
   275  				Path:              arg.Path,
   276  				Filename:          filename,
   277  				StartTime:         keybase1.ToTime(time.Now()),
   278  				IsRegularDownload: arg.IsRegularDownload,
   279  			},
   280  			opid:         opid,
   281  			safeFilename: safeFilename,
   282  			state: keybase1.DownloadState{
   283  				DownloadID: downloadID,
   284  			},
   285  		}
   286  	}()
   287  
   288  	bgCtx, cancelBtCtx := m.makeContext()
   289  	done := func(err error) {
   290  		_ = m.updateDownload(downloadID, func(d download) download {
   291  			if d.state.Done || d.state.Canceled || len(d.state.Error) > 0 {
   292  				return d
   293  			}
   294  			if errors.Cause(err) == context.Canceled {
   295  				d.state.Canceled = true
   296  			} else if err != nil {
   297  				d.state.Error = err.Error()
   298  			} else {
   299  				d.state.EndEstimate = keybase1.ToTime(time.Now())
   300  				d.state.Progress = 1
   301  				d.state.Done = true
   302  			}
   303  			return d
   304  		})
   305  		cancelBtCtx()
   306  	}
   307  	go m.monitorDownload(bgCtx, opid, downloadID, done)
   308  	go m.waitForDownload(bgCtx, downloadID, downloadPath, done)
   309  
   310  	return downloadID, nil
   311  }
   312  
   313  func (m *downloadManager) getDownloadStatus(ctx context.Context) (
   314  	status keybase1.DownloadStatus) {
   315  	m.lock.RLock()
   316  	defer m.lock.RUnlock()
   317  	for _, download := range m.downloads {
   318  		status.States = append(status.States, download.state)
   319  		if download.info.IsRegularDownload {
   320  			status.RegularDownloadIDs = append(
   321  				status.RegularDownloadIDs, download.info.DownloadID)
   322  		}
   323  	}
   324  	sort.Slice(status.RegularDownloadIDs, func(i, j int) bool {
   325  		d1, ok := m.downloads[status.RegularDownloadIDs[i]]
   326  		if !ok {
   327  			return false
   328  		}
   329  		d2, ok := m.downloads[status.RegularDownloadIDs[j]]
   330  		if !ok {
   331  			return false
   332  		}
   333  		return d1.info.StartTime.After(d2.info.StartTime)
   334  	})
   335  	return status
   336  }
   337  
   338  func (m *downloadManager) cancelDownload(
   339  	ctx context.Context, downloadID string) error {
   340  	d, err := m.getDownload(downloadID)
   341  	if err != nil {
   342  		return err
   343  	}
   344  	return m.k.SimpleFSCancel(ctx, d.opid)
   345  }
   346  
   347  func (m *downloadManager) dismissDownload(
   348  	ctx context.Context, downloadID string) {
   349  	// make sure it's canceled, but don't error if it's already dismissed.
   350  	_ = m.cancelDownload(ctx, downloadID)
   351  	defer m.publisher.PublishChange(keybase1.SubscriptionTopic_DOWNLOAD_STATUS)
   352  	m.lock.Lock()
   353  	defer m.lock.Unlock()
   354  	delete(m.downloads, downloadID)
   355  }
   356  
   357  func (m *downloadManager) getDownloadInfo(downloadID string) (keybase1.DownloadInfo, error) {
   358  	d, err := m.getDownload(downloadID)
   359  	if err != nil {
   360  		return keybase1.DownloadInfo{}, err
   361  	}
   362  	return d.info, nil
   363  }
   364  
   365  func (m *downloadManager) configureDownload(cacheDirOverride string, downloadDirOverride string) {
   366  	m.lock.Lock()
   367  	defer m.lock.Unlock()
   368  	if len(cacheDirOverride) > 0 {
   369  		m.cacheDir = cacheDirOverride
   370  	}
   371  	if len(downloadDirOverride) > 0 {
   372  		m.downloadDir = downloadDirOverride
   373  	}
   374  }