|
|
@@ -3,266 +3,266 @@ |
|
|
|
package main |
|
|
|
|
|
|
|
import ( |
|
|
|
"runtime" |
|
|
|
"os" |
|
|
|
"io" |
|
|
|
"log" |
|
|
|
"errors" |
|
|
|
"archive/zip" |
|
|
|
"path/filepath" |
|
|
|
"flag" |
|
|
|
"time" |
|
|
|
"archive/zip" |
|
|
|
"errors" |
|
|
|
"flag" |
|
|
|
"io" |
|
|
|
"log" |
|
|
|
"os" |
|
|
|
"path/filepath" |
|
|
|
"runtime" |
|
|
|
"time" |
|
|
|
) |
|
|
|
|
|
|
|
const ( |
|
|
|
DefaultZipFile string = "rxsm-update.zip" |
|
|
|
RXSMUpdaterVersion string = "v0.0.1" |
|
|
|
DefaultUpdateServer string = "https://rxsm-update.exmods.org" |
|
|
|
DefaultZipFile string = "rxsm-update.zip" |
|
|
|
RXSMUpdaterVersion string = "v0.0.1" |
|
|
|
DefaultUpdateServer string = "https://rxsm-update.exmods.org" |
|
|
|
) |
|
|
|
|
|
|
|
var ( |
|
|
|
// command line flag arguments |
|
|
|
Zipfile string = DefaultZipFile |
|
|
|
Target string |
|
|
|
Unattended bool |
|
|
|
PlatformStream string |
|
|
|
UpdateServer string |
|
|
|
TargetBinary string |
|
|
|
Delay time.Duration |
|
|
|
IsLogged bool |
|
|
|
// command line flag arguments |
|
|
|
Zipfile string = DefaultZipFile |
|
|
|
Target string |
|
|
|
Unattended bool |
|
|
|
PlatformStream string |
|
|
|
UpdateServer string |
|
|
|
TargetBinary string |
|
|
|
Delay time.Duration |
|
|
|
IsLogged bool |
|
|
|
) |
|
|
|
|
|
|
|
func init() { |
|
|
|
log.Println("RXSM Updater version", RXSMUpdaterVersion) |
|
|
|
log.Println("This program updates RXSM headlessly. Use rxsm-updater --help to view advanced features") |
|
|
|
log.Println(os.Args) |
|
|
|
flag.StringVar(&Zipfile, "zip", DefaultZipFile, "The zipfile to extract from (and download to, in unattended mode)") |
|
|
|
flag.StringVar(&Target, "target", "", "The directory to target for the update (the directory containing RXSM)") |
|
|
|
flag.BoolVar(&Unattended, "unattended", false, "Download & extract the RXSM update (WIP)") |
|
|
|
flag.StringVar(&PlatformStream, "stream", "release", "The update stream to use when retrieving updates in unattended mode") |
|
|
|
flag.StringVar(&UpdateServer, "server", DefaultUpdateServer, "The web server to use for retrieving update information") |
|
|
|
flag.DurationVar(&Delay, "wait", 0, "Time to wait before starting") |
|
|
|
flag.BoolVar(&IsLogged, "log", false, "Send output to log") |
|
|
|
log.Println("RXSM Updater version", RXSMUpdaterVersion) |
|
|
|
log.Println("This program updates RXSM headlessly. Use rxsm-updater --help to view advanced features") |
|
|
|
log.Println(os.Args) |
|
|
|
flag.StringVar(&Zipfile, "zip", DefaultZipFile, "The zipfile to extract from (and download to, in unattended mode)") |
|
|
|
flag.StringVar(&Target, "target", "", "The directory to target for the update (the directory containing RXSM)") |
|
|
|
flag.BoolVar(&Unattended, "unattended", false, "Download & extract the RXSM update (WIP)") |
|
|
|
flag.StringVar(&PlatformStream, "stream", "release", "The update stream to use when retrieving updates in unattended mode") |
|
|
|
flag.StringVar(&UpdateServer, "server", DefaultUpdateServer, "The web server to use for retrieving update information") |
|
|
|
flag.DurationVar(&Delay, "wait", 0, "Time to wait before starting") |
|
|
|
flag.BoolVar(&IsLogged, "log", false, "Send output to log") |
|
|
|
} |
|
|
|
|
|
|
|
func main() { |
|
|
|
flag.Parse() |
|
|
|
if IsLogged { |
|
|
|
f, _ := os.Create("rxsm-updater.log") |
|
|
|
log.SetOutput(f) |
|
|
|
log.Println("RXSM Updater version", RXSMUpdaterVersion) |
|
|
|
log.Println("This program updates RXSM headlessly. Use rxsm-updater --help to view advanced features") |
|
|
|
log.Println(os.Args) |
|
|
|
} |
|
|
|
if Delay != 0 { |
|
|
|
log.Println("Waiting", Delay.Seconds(), "second(s)") |
|
|
|
time.Sleep(Delay) |
|
|
|
} |
|
|
|
if Unattended { |
|
|
|
log.Println("Attempting WIP Unattended update") |
|
|
|
log.Println("This is not advised, and will fail if an updater update is necessary") |
|
|
|
URL, isUpdatable, ok := CheckForUpdate(UpdateServer, "", runtime.GOOS+"/"+runtime.GOARCH+"/"+PlatformStream) |
|
|
|
if !(ok && isUpdatable && URL != "") { |
|
|
|
log.Println("No update found") |
|
|
|
return |
|
|
|
} |
|
|
|
downloadErr := DownloadRXSMUpdate(URL, func(int, string){}) |
|
|
|
if downloadErr != nil { |
|
|
|
log.Println(downloadErr) |
|
|
|
return |
|
|
|
} |
|
|
|
proc, forkErr := ForkRXSMUpdate() |
|
|
|
if forkErr != nil { |
|
|
|
log.Println(forkErr) |
|
|
|
return |
|
|
|
} |
|
|
|
log.Println("Extraction forked to", proc.Pid) |
|
|
|
} else { |
|
|
|
log.Println("Installing from", Zipfile) |
|
|
|
installErr := InstallRXSMUpdate(func(int, string){}) |
|
|
|
if installErr != nil { |
|
|
|
log.Println(installErr) |
|
|
|
return |
|
|
|
} |
|
|
|
} |
|
|
|
log.Println("Operation complete") |
|
|
|
// TODO: implement callback for Qt window |
|
|
|
flag.Parse() |
|
|
|
if IsLogged { |
|
|
|
f, _ := os.Create("rxsm-updater.log") |
|
|
|
log.SetOutput(f) |
|
|
|
log.Println("RXSM Updater version", RXSMUpdaterVersion) |
|
|
|
log.Println("This program updates RXSM headlessly. Use rxsm-updater --help to view advanced features") |
|
|
|
log.Println(os.Args) |
|
|
|
} |
|
|
|
if Delay != 0 { |
|
|
|
log.Println("Waiting", Delay.Seconds(), "second(s)") |
|
|
|
time.Sleep(Delay) |
|
|
|
} |
|
|
|
if Unattended { |
|
|
|
log.Println("Attempting WIP Unattended update") |
|
|
|
log.Println("This is not advised, and will fail if an updater update is necessary") |
|
|
|
URL, isUpdatable, ok := CheckForUpdate(UpdateServer, "", runtime.GOOS+"/"+runtime.GOARCH+"/"+PlatformStream) |
|
|
|
if !(ok && isUpdatable && URL != "") { |
|
|
|
log.Println("No update found") |
|
|
|
return |
|
|
|
} |
|
|
|
downloadErr := DownloadRXSMUpdate(URL, func(int, string) {}) |
|
|
|
if downloadErr != nil { |
|
|
|
log.Println(downloadErr) |
|
|
|
return |
|
|
|
} |
|
|
|
proc, forkErr := ForkRXSMUpdate() |
|
|
|
if forkErr != nil { |
|
|
|
log.Println(forkErr) |
|
|
|
return |
|
|
|
} |
|
|
|
log.Println("Extraction forked to", proc.Pid) |
|
|
|
} else { |
|
|
|
log.Println("Installing from", Zipfile) |
|
|
|
installErr := InstallRXSMUpdate(func(int, string) {}) |
|
|
|
if installErr != nil { |
|
|
|
log.Println(installErr) |
|
|
|
return |
|
|
|
} |
|
|
|
} |
|
|
|
log.Println("Operation complete") |
|
|
|
// TODO: implement callback for Qt window |
|
|
|
} |
|
|
|
|
|
|
|
func DownloadRXSMUpdateQuiet(URL string) { |
|
|
|
DownloadRXSMUpdate(URL, func(int, string){}) |
|
|
|
DownloadRXSMUpdate(URL, func(int, string) {}) |
|
|
|
} |
|
|
|
|
|
|
|
func DownloadRXSMUpdate(URL string, statusCallback func(progress int, description string)) (error){ |
|
|
|
// progress is out of 100 |
|
|
|
statusCallback(25, "Downloading") |
|
|
|
log.Println("Downloading update from "+URL) |
|
|
|
f, createErr := os.Create(Zipfile) |
|
|
|
if createErr != nil { |
|
|
|
log.Println("Error creating temporary update file") |
|
|
|
statusCallback(-1, "Error creating update temporary file") |
|
|
|
return createErr |
|
|
|
} |
|
|
|
defer f.Close() |
|
|
|
ok := DownloadUpdate(URL, f) |
|
|
|
if !ok { |
|
|
|
log.Println("Error downloading update") |
|
|
|
statusCallback(-1, "Download failed") |
|
|
|
return errors.New("download failed in update API") |
|
|
|
} |
|
|
|
statusCallback(50, "Installing Updater") |
|
|
|
f.Sync() |
|
|
|
f.Seek(0,0) |
|
|
|
fStat, statErr := f.Stat() |
|
|
|
if statErr != nil { |
|
|
|
log.Println("Error getting download temp file stat") |
|
|
|
return statErr |
|
|
|
} |
|
|
|
log.Println("Downloaded", fStat.Size(), "bytes") |
|
|
|
statusCallback(75, "Extracting Updater") |
|
|
|
// TODO: implement way to have this actually work |
|
|
|
zipFile, zipErr := zip.NewReader(f, fStat.Size()) |
|
|
|
if zipErr != nil { |
|
|
|
log.Println("Error creating zip reader") |
|
|
|
log.Println(zipErr) |
|
|
|
return zipErr |
|
|
|
} |
|
|
|
for _, f := range zipFile.File { |
|
|
|
if !f.FileHeader.Mode().IsDir() { |
|
|
|
filename := filepath.Clean(f.FileHeader.Name) |
|
|
|
var updaterFilename string |
|
|
|
if runtime.GOOS == "windows" { |
|
|
|
updaterFilename = "rxsm-updater.exe" |
|
|
|
TargetBinary = "rxsm-updater2.exe" |
|
|
|
} else { |
|
|
|
updaterFilename = "rxsm-updater" |
|
|
|
TargetBinary = "rxsm-updater2" |
|
|
|
} |
|
|
|
if len(filename) >= len(updaterFilename) && filename[:len(updaterFilename)] == updaterFilename { |
|
|
|
fileReadCloser, openErr := f.Open() |
|
|
|
if openErr != nil { |
|
|
|
log.Println("Error opening updater in zip archive") |
|
|
|
return openErr |
|
|
|
} |
|
|
|
defer fileReadCloser.Close() |
|
|
|
destFile, createErr := os.Create(TargetBinary) |
|
|
|
if createErr != nil { |
|
|
|
log.Println("Error creating updater file") |
|
|
|
return createErr |
|
|
|
} |
|
|
|
defer destFile.Close() |
|
|
|
_, copyErr := io.Copy(destFile, fileReadCloser) |
|
|
|
if copyErr != nil { |
|
|
|
log.Println("Error copying/extracting updater") |
|
|
|
return copyErr |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
statusCallback(100, "Complete") |
|
|
|
return nil |
|
|
|
func DownloadRXSMUpdate(URL string, statusCallback func(progress int, description string)) error { |
|
|
|
// progress is out of 100 |
|
|
|
statusCallback(25, "Downloading") |
|
|
|
log.Println("Downloading update from " + URL) |
|
|
|
f, createErr := os.Create(Zipfile) |
|
|
|
if createErr != nil { |
|
|
|
log.Println("Error creating temporary update file") |
|
|
|
statusCallback(-1, "Error creating update temporary file") |
|
|
|
return createErr |
|
|
|
} |
|
|
|
defer f.Close() |
|
|
|
ok := DownloadUpdate(URL, f) |
|
|
|
if !ok { |
|
|
|
log.Println("Error downloading update") |
|
|
|
statusCallback(-1, "Download failed") |
|
|
|
return errors.New("download failed in update API") |
|
|
|
} |
|
|
|
statusCallback(50, "Installing Updater") |
|
|
|
f.Sync() |
|
|
|
f.Seek(0, 0) |
|
|
|
fStat, statErr := f.Stat() |
|
|
|
if statErr != nil { |
|
|
|
log.Println("Error getting download temp file stat") |
|
|
|
return statErr |
|
|
|
} |
|
|
|
log.Println("Downloaded", fStat.Size(), "bytes") |
|
|
|
statusCallback(75, "Extracting Updater") |
|
|
|
// TODO: implement way to have this actually work |
|
|
|
zipFile, zipErr := zip.NewReader(f, fStat.Size()) |
|
|
|
if zipErr != nil { |
|
|
|
log.Println("Error creating zip reader") |
|
|
|
log.Println(zipErr) |
|
|
|
return zipErr |
|
|
|
} |
|
|
|
for _, f := range zipFile.File { |
|
|
|
if !f.FileHeader.Mode().IsDir() { |
|
|
|
filename := filepath.Clean(f.FileHeader.Name) |
|
|
|
var updaterFilename string |
|
|
|
if runtime.GOOS == "windows" { |
|
|
|
updaterFilename = "rxsm-updater.exe" |
|
|
|
TargetBinary = "rxsm-updater2.exe" |
|
|
|
} else { |
|
|
|
updaterFilename = "rxsm-updater" |
|
|
|
TargetBinary = "rxsm-updater2" |
|
|
|
} |
|
|
|
if len(filename) >= len(updaterFilename) && filename[:len(updaterFilename)] == updaterFilename { |
|
|
|
fileReadCloser, openErr := f.Open() |
|
|
|
if openErr != nil { |
|
|
|
log.Println("Error opening updater in zip archive") |
|
|
|
return openErr |
|
|
|
} |
|
|
|
defer fileReadCloser.Close() |
|
|
|
destFile, createErr := os.Create(TargetBinary) |
|
|
|
if createErr != nil { |
|
|
|
log.Println("Error creating updater file") |
|
|
|
return createErr |
|
|
|
} |
|
|
|
defer destFile.Close() |
|
|
|
_, copyErr := io.Copy(destFile, fileReadCloser) |
|
|
|
if copyErr != nil { |
|
|
|
log.Println("Error copying/extracting updater") |
|
|
|
return copyErr |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
statusCallback(100, "Complete") |
|
|
|
return nil |
|
|
|
} |
|
|
|
|
|
|
|
func ForkRXSMUpdate() (process *os.Process, err error) { |
|
|
|
if runtime.GOOS == "windows" { |
|
|
|
return os.StartProcess(TargetBinary, []string{TargetBinary, "--zip", Zipfile, "--target", Target}, nil) |
|
|
|
} else { |
|
|
|
return os.StartProcess(TargetBinary, []string{TargetBinary, "--zip", Zipfile, "--target", Target}, nil) |
|
|
|
} |
|
|
|
if runtime.GOOS == "windows" { |
|
|
|
return os.StartProcess(TargetBinary, []string{TargetBinary, "--zip", Zipfile, "--target", Target}, nil) |
|
|
|
} else { |
|
|
|
return os.StartProcess(TargetBinary, []string{TargetBinary, "--zip", Zipfile, "--target", Target}, nil) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
func InstallRXSMUpdate(statusCallback func(progress int, description string)) (error){ |
|
|
|
f, openErr := os.Open(Zipfile) |
|
|
|
if openErr != nil { |
|
|
|
log.Println("Error opening zip file") |
|
|
|
statusCallback(-1, "Opening zip file failed") |
|
|
|
return openErr |
|
|
|
} |
|
|
|
statusCallback(1, "Extracting") |
|
|
|
f.Sync() |
|
|
|
f.Seek(0,0) |
|
|
|
fStat, statErr := f.Stat() |
|
|
|
if statErr != nil { |
|
|
|
log.Println("Error retrieving file stats") |
|
|
|
statusCallback(-1, "Extracting zip file failed") |
|
|
|
return statErr |
|
|
|
} |
|
|
|
unpackErr := unpackRXSMInstall(f, fStat.Size(), func(p int, d string){statusCallback(1, d)}) |
|
|
|
if unpackErr != nil { |
|
|
|
log.Println("Error extracting update") |
|
|
|
statusCallback(-1, "Unpacking failed") |
|
|
|
return unpackErr |
|
|
|
} |
|
|
|
statusCallback(2, "Installing") |
|
|
|
// TODO: is there installation required? |
|
|
|
rmErr := os.Remove(Zipfile) |
|
|
|
if rmErr != nil { |
|
|
|
log.Println("Error removing download file") |
|
|
|
statusCallback(-1, "Installing failed") |
|
|
|
return rmErr |
|
|
|
} |
|
|
|
return nil |
|
|
|
func InstallRXSMUpdate(statusCallback func(progress int, description string)) error { |
|
|
|
f, openErr := os.Open(Zipfile) |
|
|
|
if openErr != nil { |
|
|
|
log.Println("Error opening zip file") |
|
|
|
statusCallback(-1, "Opening zip file failed") |
|
|
|
return openErr |
|
|
|
} |
|
|
|
statusCallback(1, "Extracting") |
|
|
|
f.Sync() |
|
|
|
f.Seek(0, 0) |
|
|
|
fStat, statErr := f.Stat() |
|
|
|
if statErr != nil { |
|
|
|
log.Println("Error retrieving file stats") |
|
|
|
statusCallback(-1, "Extracting zip file failed") |
|
|
|
return statErr |
|
|
|
} |
|
|
|
unpackErr := unpackRXSMInstall(f, fStat.Size(), func(p int, d string) { statusCallback(1, d) }) |
|
|
|
if unpackErr != nil { |
|
|
|
log.Println("Error extracting update") |
|
|
|
statusCallback(-1, "Unpacking failed") |
|
|
|
return unpackErr |
|
|
|
} |
|
|
|
statusCallback(2, "Installing") |
|
|
|
// TODO: is there installation required? |
|
|
|
rmErr := os.Remove(Zipfile) |
|
|
|
if rmErr != nil { |
|
|
|
log.Println("Error removing download file") |
|
|
|
statusCallback(-1, "Installing failed") |
|
|
|
return rmErr |
|
|
|
} |
|
|
|
return nil |
|
|
|
} |
|
|
|
|
|
|
|
func unpackRXSMInstallPath(path string) (error) { |
|
|
|
f, openErr := os.Open(path) |
|
|
|
if openErr != nil { |
|
|
|
return openErr |
|
|
|
} |
|
|
|
defer f.Close() |
|
|
|
fStat, statErr := f.Stat() |
|
|
|
if statErr != nil { |
|
|
|
return statErr |
|
|
|
} |
|
|
|
return unpackRXSMInstall(f, fStat.Size(), func(int, string){}) |
|
|
|
func unpackRXSMInstallPath(path string) error { |
|
|
|
f, openErr := os.Open(path) |
|
|
|
if openErr != nil { |
|
|
|
return openErr |
|
|
|
} |
|
|
|
defer f.Close() |
|
|
|
fStat, statErr := f.Stat() |
|
|
|
if statErr != nil { |
|
|
|
return statErr |
|
|
|
} |
|
|
|
return unpackRXSMInstall(f, fStat.Size(), func(int, string) {}) |
|
|
|
} |
|
|
|
|
|
|
|
func unpackRXSMInstall(reader io.ReaderAt, size int64, statusCallback func(progress int, description string)) (error) { |
|
|
|
// progress is out of 100 |
|
|
|
// open zip |
|
|
|
zipFile, zipErr := zip.NewReader(reader, size) |
|
|
|
if zipErr != nil { |
|
|
|
return zipErr |
|
|
|
} |
|
|
|
// NOTE: this could use go routines, but doesn't to keep your PC (& RXSM) usable while updating |
|
|
|
// (And also to make the loading bar coherent) |
|
|
|
for i, f := range zipFile.File { |
|
|
|
if !f.FileHeader.Mode().IsDir() { |
|
|
|
filename := filepath.Clean(f.FileHeader.Name) |
|
|
|
statusCallback(100*(i+1)/len(zipFile.File), "Unpacking "+filename) |
|
|
|
if filepath.IsAbs(filename) { |
|
|
|
if runtime.GOOS == "windows" { |
|
|
|
filename = filename[4:] // D:\\path\to\keep -> path\to\keep |
|
|
|
} else { |
|
|
|
filename = filename[1:] // /path/to/keep -> path/to/keep |
|
|
|
} |
|
|
|
} |
|
|
|
if len(filename) > len(runtime.GOOS)+1 { |
|
|
|
// remove windows/ or darwin/ or linux/ from path if exists |
|
|
|
if filename[:len(runtime.GOOS)] == runtime.GOOS { |
|
|
|
filename = filename[len(runtime.GOOS)+1:] |
|
|
|
} |
|
|
|
} |
|
|
|
filename = filepath.Join(Target, filename) |
|
|
|
dirErr := os.MkdirAll(filepath.Dir(filename), os.ModeDir | os.ModePerm) |
|
|
|
if dirErr != nil { |
|
|
|
log.Println("Dir err "+filepath.Dir(filename)) |
|
|
|
return dirErr |
|
|
|
} |
|
|
|
newFile, createErr := os.Create(filename) |
|
|
|
if createErr != nil { |
|
|
|
return createErr |
|
|
|
} |
|
|
|
srcFileReadCloser, zipOpenErr := f.Open() |
|
|
|
if zipOpenErr != nil { |
|
|
|
return zipOpenErr |
|
|
|
} |
|
|
|
defer srcFileReadCloser.Close() |
|
|
|
_, copyErr := io.Copy(newFile, srcFileReadCloser) |
|
|
|
if copyErr != nil { |
|
|
|
return copyErr |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
return nil |
|
|
|
func unpackRXSMInstall(reader io.ReaderAt, size int64, statusCallback func(progress int, description string)) error { |
|
|
|
// progress is out of 100 |
|
|
|
// open zip |
|
|
|
zipFile, zipErr := zip.NewReader(reader, size) |
|
|
|
if zipErr != nil { |
|
|
|
return zipErr |
|
|
|
} |
|
|
|
// NOTE: this could use go routines, but doesn't to keep your PC (& RXSM) usable while updating |
|
|
|
// (And also to make the loading bar coherent) |
|
|
|
for i, f := range zipFile.File { |
|
|
|
if !f.FileHeader.Mode().IsDir() { |
|
|
|
filename := filepath.Clean(f.FileHeader.Name) |
|
|
|
statusCallback(100*(i+1)/len(zipFile.File), "Unpacking "+filename) |
|
|
|
if filepath.IsAbs(filename) { |
|
|
|
if runtime.GOOS == "windows" { |
|
|
|
filename = filename[4:] // D:\\path\to\keep -> path\to\keep |
|
|
|
} else { |
|
|
|
filename = filename[1:] // /path/to/keep -> path/to/keep |
|
|
|
} |
|
|
|
} |
|
|
|
if len(filename) > len(runtime.GOOS)+1 { |
|
|
|
// remove windows/ or darwin/ or linux/ from path if exists |
|
|
|
if filename[:len(runtime.GOOS)] == runtime.GOOS { |
|
|
|
filename = filename[len(runtime.GOOS)+1:] |
|
|
|
} |
|
|
|
} |
|
|
|
filename = filepath.Join(Target, filename) |
|
|
|
dirErr := os.MkdirAll(filepath.Dir(filename), os.ModeDir|os.ModePerm) |
|
|
|
if dirErr != nil { |
|
|
|
log.Println("Dir err " + filepath.Dir(filename)) |
|
|
|
return dirErr |
|
|
|
} |
|
|
|
newFile, createErr := os.Create(filename) |
|
|
|
if createErr != nil { |
|
|
|
return createErr |
|
|
|
} |
|
|
|
srcFileReadCloser, zipOpenErr := f.Open() |
|
|
|
if zipOpenErr != nil { |
|
|
|
return zipOpenErr |
|
|
|
} |
|
|
|
defer srcFileReadCloser.Close() |
|
|
|
_, copyErr := io.Copy(newFile, srcFileReadCloser) |
|
|
|
if copyErr != nil { |
|
|
|
return copyErr |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
return nil |
|
|
|
} |