diff --git a/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt b/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt index ae4f351..a15636b 100644 --- a/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt +++ b/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt @@ -49,91 +49,90 @@ object ShareFileHelper : libtailscale.ShareFileHelper { return file.uri.toString() to os } - private fun openWriterFD(fileName: String, offset: Long): Pair { - - val ctx = appContext ?: return "" to null - val dirUri = savedUri ?: return "" to null - val dir = DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri)) ?: return "" to null - - // Reuse existing doc if it exists + @Throws(IOException::class) + private fun openWriterFD(fileName: String, offset: Long): Pair { + val ctx = appContext ?: throw IOException("App context not initialized") + val dirUri = savedUri ?: throw IOException("No directory URI") + val dir = + DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri)) + ?: throw IOException("Invalid tree URI: $dirUri") val file = dir.findFile(fileName) ?: dir.createFile("application/octet-stream", fileName) - ?: return "" to null + ?: throw IOException("Failed to create file: $fileName") - // Always get a ParcelFileDescriptor so we can sync - val pfd = ctx.contentResolver.openFileDescriptor(file.uri, "rw") ?: return "" to null + val pfd = + ctx.contentResolver.openFileDescriptor(file.uri, "rw") + ?: throw IOException("Failed to open file descriptor for ${file.uri}") val fos = FileOutputStream(pfd.fileDescriptor) if (offset != 0L) fos.channel.position(offset) else fos.channel.truncate(0) - return file.uri.toString() to SeekableOutputStream(fos, pfd) } private val currentUri = ConcurrentHashMap() - override fun openFileWriter(fileName: String): libtailscale.OutputStream { - val (uri, stream) = openWriterFD(fileName, 0) - currentUri[fileName] = uri // 🠚 cache the exact doc we opened - return OutputStreamAdapter(stream ?: OutputStream.nullOutputStream()) - } - - override fun openFileWriterAt(fileName: String, offset: Long): libtailscale.OutputStream { + @Throws(IOException::class) + override fun openFileWriter(fileName: String, offset: Long): libtailscale.OutputStream { val (uri, stream) = openWriterFD(fileName, offset) + if (stream == null) { + throw IOException("Failed to open file writer for $fileName") + } currentUri[fileName] = uri - return OutputStreamAdapter(stream ?: OutputStream.nullOutputStream()) + return OutputStreamAdapter(stream) } - override fun openFileURI(fileName: String): String { + @Throws(IOException::class) + override fun getFileURI(fileName: String): String { currentUri[fileName]?.let { return it } - val ctx = appContext ?: return "" - val dirStr = savedUri ?: return "" - val dir = DocumentFile.fromTreeUri(ctx, Uri.parse(dirStr)) ?: return "" - val file = dir.findFile(fileName) ?: return "" - val uri = file.uri.toString() + val ctx = appContext ?: throw IOException("App context not initialized") + val dirStr = savedUri ?: throw IOException("No saved directory URI") + val dir = + DocumentFile.fromTreeUri(ctx, Uri.parse(dirStr)) + ?: throw IOException("Invalid tree URI: $dirStr") + val file = dir.findFile(fileName) ?: throw IOException("File not found: $fileName") + val uri = file.uri.toString() currentUri[fileName] = uri return uri } - override fun renamePartialFile( - partialUri: String, - targetDirUri: String, - targetName: String - ): String { + @Throws(IOException::class) + override fun renameFile(oldPath: String, targetName: String): String { val ctx = appContext ?: throw IOException("not initialized") - val srcUri = Uri.parse(partialUri) + val dirUri = savedUri ?: throw IOException("directory not set") + val srcUri = Uri.parse(oldPath) val dir = - DocumentFile.fromTreeUri(ctx, Uri.parse(targetDirUri)) - ?: throw IOException("cannot open dir $targetDirUri") - + DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri)) + ?: throw IOException("cannot open dir $dirUri") + var finalName = targetName dir.findFile(finalName)?.let { existing -> if (lengthOfUri(ctx, existing.uri) == 0L) { - existing.delete() // remove stale 0‑byte file + existing.delete() } else { finalName = generateNewFilename(finalName) } } - + try { DocumentsContract.renameDocument(ctx.contentResolver, srcUri, finalName)?.also { newUri -> runCatching { ctx.contentResolver.delete(srcUri, null, null) } cleanupPartials(dir, targetName) return newUri.toString() } - } catch (_: Exception) { - // rename not supported; fall through to copy‑delete - } + } catch (e: Exception) { + TSLog.w("renameFile", "renameDocument fallback triggered for $srcUri -> $finalName: ${e.message}") - // fallback - copy contents then delete source + } + val dest = dir.createFile("application/octet-stream", finalName) ?: throw IOException("createFile failed for $finalName") - + ctx.contentResolver.openInputStream(srcUri).use { inp -> ctx.contentResolver.openOutputStream(dest.uri, "w").use { out -> if (inp == null || out == null) { @@ -143,7 +142,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper { inp.copyTo(out) } } - // delete the original .partial + ctx.contentResolver.delete(srcUri, null, null) cleanupPartials(dir, targetName) return dest.uri.toString() @@ -163,33 +162,35 @@ object ShareFileHelper : libtailscale.ShareFileHelper { } @Throws(IOException::class) - override fun deleteFile(uriString: String) { + override fun deleteFile(uri: String) { val ctx = appContext ?: throw IOException("DeleteFile: not initialized") - val uri = Uri.parse(uriString) + val uri = Uri.parse(uri) val doc = DocumentFile.fromSingleUri(ctx, uri) - ?: throw IOException("DeleteFile: cannot resolve URI $uriString") + ?: throw IOException("DeleteFile: cannot resolve URI $uri") if (!doc.delete()) { - throw IOException("DeleteFile: delete() returned false for $uriString") + throw IOException("DeleteFile: delete() returned false for $uri") } } - override fun treeURI(): String = savedUri ?: throw IllegalStateException("not initialized") - + @Throws(IOException::class) override fun getFileInfo(fileName: String): String { - val context = appContext ?: return "" - val dirUri = savedUri ?: return "" - val dir = DocumentFile.fromTreeUri(context, Uri.parse(dirUri)) ?: return "" + val context = appContext ?: throw IOException("app context not initialized") + val dirUri = savedUri ?: throw IOException("SAF URI not initialized") + val dir = + DocumentFile.fromTreeUri(context, Uri.parse(dirUri)) + ?: throw IOException("could not resolve SAF root") - val file = dir.findFile(fileName) ?: return "" + val file = + dir.findFile(fileName) ?: throw IOException("file \"$fileName\" not found in SAF directory") - val name = file.name ?: return "" + val name = file.name ?: throw IOException("file name missing for $fileName") val size = file.length() - val modTime = file.lastModified() // milliseconds since epoch + val modTime = file.lastModified() - return """{"name":${jsonEscape(name)},"size":$size,"modTime":$modTime}""" + return """{"name":${JSONObject.quote(name)},"size":$size,"modTime":$modTime}""" } private fun jsonEscape(s: String): String { @@ -216,71 +217,66 @@ object ShareFileHelper : libtailscale.ShareFileHelper { .toTypedArray() } - override fun listPartialFilesJSON(suffix: String): String { - return listPartialFiles(suffix) - .joinToString(prefix = "[\"", separator = "\",\"", postfix = "\"]") + @Throws(IOException::class) + override fun listFilesJSON(suffix: String): String { + val list = listPartialFiles(suffix) + if (list.isEmpty()) { + throw IOException("no files found matching suffix \"$suffix\"") + } + return list.joinToString(prefix = "[\"", separator = "\",\"", postfix = "\"]") } - override fun openPartialFileReader(name: String): libtailscale.InputStream? { - val context = appContext ?: return null - val rootUri = savedUri ?: return null - val dir = DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) ?: return null + @Throws(IOException::class) + override fun openFileReader(name: String): libtailscale.InputStream { + val context = appContext ?: throw IOException("app context not initialized") + val rootUri = savedUri ?: throw IOException("SAF URI not initialized") + val dir = + DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) + ?: throw IOException("could not open SAF root") - // We know `name` includes the suffix (e.g. "..partial"), but the actual - // file in SAF might include extra bits, so let's just match by that suffix. - // You could also match exactly `endsWith(name)` if the filenames line up - val suffix = name.substringAfterLast('.', ".$name") // or hard-code ".partial" + val suffix = name.substringAfterLast('.', ".$name") val file = dir.listFiles().firstOrNull { val fname = it.name ?: return@firstOrNull false - // call the String overload explicitly: - fname.endsWith(suffix, /*ignoreCase=*/ false) - } - ?: run { - TSLog.d("ShareFileHelper", "no file ending with $suffix in SAF directory") - return null - } + fname.endsWith(suffix, ignoreCase = false) + } ?: throw IOException("no file ending with \"$suffix\" in SAF directory") - TSLog.d("ShareFileHelper", "found SAF file ${file.name}, opening") val inStream = context.contentResolver.openInputStream(file.uri) - ?: run { - TSLog.d("ShareFileHelper", "openInputStream returned null for ${file.uri}") - return null - } + ?: throw IOException("openInputStream returned null for ${file.uri}") + return InputStreamAdapter(inStream) } -} -private class SeekableOutputStream( - private val fos: FileOutputStream, - private val pfd: ParcelFileDescriptor -) : OutputStream() { + private class SeekableOutputStream( + private val fos: FileOutputStream, + private val pfd: ParcelFileDescriptor + ) : OutputStream() { - private var closed = false + private var closed = false - override fun write(b: Int) = fos.write(b) + override fun write(b: Int) = fos.write(b) - override fun write(b: ByteArray) = fos.write(b) + override fun write(b: ByteArray) = fos.write(b) - override fun write(b: ByteArray, off: Int, len: Int) { - fos.write(b, off, len) - } + override fun write(b: ByteArray, off: Int, len: Int) { + fos.write(b, off, len) + } - override fun close() { - if (!closed) { - closed = true - try { - fos.flush() - fos.fd.sync() // blocks until data + metadata are durable - val size = fos.channel.size() - } finally { - fos.close() - pfd.close() + override fun close() { + if (!closed) { + closed = true + try { + fos.flush() + fos.fd.sync() // blocks until data + metadata are durable + } finally { + fos.close() + pfd.close() + } } } - } - override fun flush() = fos.flush() + override fun flush() = fos.flush() + } } diff --git a/go.mod b/go.mod index 73ff6e6..05da9e2 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,9 @@ module github.com/tailscale/tailscale-android go 1.24.4 require ( - github.com/tailscale/wireguard-go v0.0.0-20250530210235-65cd6eed7d7f + github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab - tailscale.com v1.85.0-pre.0.20250627205655-0a64e86a0df8 + tailscale.com v1.87.0-pre.0.20250801224156-0f15e4419683 ) require ( diff --git a/go.sum b/go.sum index 868652f..3dc2046 100644 --- a/go.sum +++ b/go.sum @@ -163,8 +163,8 @@ github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:U github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= -github.com/tailscale/wireguard-go v0.0.0-20250530210235-65cd6eed7d7f h1:vg3PmQdq1BbB2V81iC1VBICQtfwbVGZ/4A/p7QKXTK0= -github.com/tailscale/wireguard-go v0.0.0-20250530210235-65cd6eed7d7f/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= +github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw= +github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= @@ -235,5 +235,5 @@ howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -tailscale.com v1.85.0-pre.0.20250627205655-0a64e86a0df8 h1:gR3XF35IWpV4WhON27gR2vd8ypXbnjnrj5WreLWFxWk= -tailscale.com v1.85.0-pre.0.20250627205655-0a64e86a0df8/go.mod h1:zrtwlwmFfEWbUz77UN58gaLADx4rXSecFhGO+XW0JbU= +tailscale.com v1.87.0-pre.0.20250801224156-0f15e4419683 h1:meEUX1Nsr5SaXiaeivOGG4c7gsQm/P3Jr3dzbtE0j6k= +tailscale.com v1.87.0-pre.0.20250801224156-0f15e4419683/go.mod h1:Lm8dnzU2i/Emw15r6sl3FRNp/liSQ/nYw6ZSQvIdZ1M= diff --git a/libtailscale/backend.go b/libtailscale/backend.go index c8d5f4a..bb1704d 100644 --- a/libtailscale/backend.go +++ b/libtailscale/backend.go @@ -337,7 +337,7 @@ func (a *App) newBackend(dataDir string, appCtx AppContext, store *stateStore, } lb, err := ipnlocal.NewLocalBackend(logf, logID.Public(), sys, 0) if ext, ok := ipnlocal.GetExt[*taildrop.Extension](lb); ok { - ext.SetFileOps(NewAndroidFileOps(a.shareFileHelper)) + ext.SetFileOps(newAndroidFileOps(a.shareFileHelper)) ext.SetDirectFileRoot(a.directFileRoot) } diff --git a/libtailscale/fileops.go b/libtailscale/fileops.go index 8daf9ff..769cd91 100644 --- a/libtailscale/fileops.go +++ b/libtailscale/fileops.go @@ -4,7 +4,6 @@ package libtailscale import ( "encoding/json" - "fmt" "io" "os" "time" @@ -12,47 +11,47 @@ import ( "tailscale.com/feature/taildrop" ) -// AndroidFileOps implements the FileOps interface using the Android ShareFileHelper. -type AndroidFileOps struct { +// androidFileOps implements [taildrop.FileOps] using the Android ShareFileHelper. +type androidFileOps struct { helper ShareFileHelper } -var _ taildrop.FileOps = (*AndroidFileOps)(nil) +var _ taildrop.FileOps = (*androidFileOps)(nil) -func NewAndroidFileOps(helper ShareFileHelper) *AndroidFileOps { - return &AndroidFileOps{helper: helper} +func newAndroidFileOps(helper ShareFileHelper) *androidFileOps { + return &androidFileOps{helper: helper} } -func (ops *AndroidFileOps) OpenWriter(name string, offset int64, perm os.FileMode) (io.WriteCloser, string, error) { - var wc OutputStream - if offset == 0 { - wc = ops.helper.OpenFileWriter(name) - } else { - wc = ops.helper.OpenFileWriterAt(name, offset) +func (ops *androidFileOps) OpenWriter(name string, offset int64, _ os.FileMode) (io.WriteCloser, string, error) { + wc, err := ops.helper.OpenFileWriter(name, offset) + if err != nil { + return nil, "", err } - if wc == nil { - return nil, "", fmt.Errorf("OpenFileWriter returned nil for %q", name) + uri, err := ops.helper.GetFileURI(name) + if err != nil { + wc.Close() + return nil, "", err } - uri := ops.helper.OpenFileURI(name) return wc, uri, nil } -func (ops *AndroidFileOps) Remove(baseName string) error { - uri := ops.helper.OpenFileURI(baseName) +func (ops *androidFileOps) Remove(baseName string) error { + uri, err := ops.helper.GetFileURI(baseName) + if err != nil { + return err + } return ops.helper.DeleteFile(uri) } -func (ops *AndroidFileOps) Rename(oldPath, newName string) (string, error) { - tree := ops.helper.TreeURI() - newURI := ops.helper.RenamePartialFile(oldPath, tree, newName) - if newURI == "" { - return "", fmt.Errorf("failed to rename partial file via SAF") - } - return newURI, nil +func (ops *androidFileOps) Rename(oldPath, newName string) (string, error) { + return ops.helper.RenameFile(oldPath, newName) } -func (ops *AndroidFileOps) ListFiles() ([]string, error) { - namesJSON := ops.helper.ListPartialFilesJSON("") +func (ops *androidFileOps) ListFiles() ([]string, error) { + namesJSON, err := ops.helper.ListFilesJSON("") + if err != nil { + return nil, err + } var names []string if err := json.Unmarshal([]byte(namesJSON), &names); err != nil { return nil, err @@ -60,43 +59,42 @@ func (ops *AndroidFileOps) ListFiles() ([]string, error) { return names, nil } -func (ops *AndroidFileOps) OpenReader(name string) (io.ReadCloser, error) { - in := ops.helper.OpenPartialFileReader(name) - if in == nil { - return nil, fmt.Errorf("OpenPartialFileReader returned nil for %q", name) +func (ops *androidFileOps) OpenReader(name string) (io.ReadCloser, error) { + in, err := ops.helper.OpenFileReader(name) + if err != nil { + return nil, err } return adaptInputStream(in), nil } -func (ops *AndroidFileOps) Stat(name string) (os.FileInfo, error) { - infoJSON := ops.helper.GetFileInfo(name) - if infoJSON == "" { - return nil, os.ErrNotExist - } - var info struct { - Name string `json:"name"` - Size int64 `json:"size"` - ModTime int64 `json:"modTime"` // Unix millis +func (ops *androidFileOps) Stat(name string) (os.FileInfo, error) { + infoJSON, err := ops.helper.GetFileInfo(name) + if err != nil { + return nil, err } - if err := json.Unmarshal([]byte(infoJSON), &info); err != nil { + var fi androidFileInfo + if err := json.Unmarshal([]byte(infoJSON), &fi); err != nil { return nil, err } - return &androidFileInfo{ - name: info.Name, - size: info.Size, - modTime: time.UnixMilli(info.ModTime), - }, nil + return &fi, nil +} + +type androidFileInfoJSON struct { + Name string `json:"name"` + Size int64 `json:"size"` + ModTime int64 `json:"modTime"` } type androidFileInfo struct { - name string - size int64 - modTime time.Time + data androidFileInfoJSON } -func (fi *androidFileInfo) Name() string { return fi.name } -func (fi *androidFileInfo) Size() int64 { return fi.size } +// compile-time check +var _ os.FileInfo = (*androidFileInfo)(nil) + +func (fi *androidFileInfo) Name() string { return fi.data.Name } +func (fi *androidFileInfo) Size() int64 { return fi.data.Size } func (fi *androidFileInfo) Mode() os.FileMode { return 0o600 } -func (fi *androidFileInfo) ModTime() time.Time { return fi.modTime } +func (fi *androidFileInfo) ModTime() time.Time { return time.UnixMilli(fi.data.ModTime) } func (fi *androidFileInfo) IsDir() bool { return false } -func (fi *androidFileInfo) Sys() interface{} { return nil } +func (fi *androidFileInfo) Sys() any { return nil } diff --git a/libtailscale/interfaces.go b/libtailscale/interfaces.go index 111bb62..67a108c 100644 --- a/libtailscale/interfaces.go +++ b/libtailscale/interfaces.go @@ -175,45 +175,36 @@ type OutputStream interface { // ShareFileHelper corresponds to the Kotlin ShareFileHelper class type ShareFileHelper interface { - // OpenFileWriter creates or truncates a file named fileName - // and returns an OutputStream for writing to it from the beginning. - // Returns nil if the file cannot be opened. - OpenFileWriter(fileName string) OutputStream - - // OpenFileWriterAt opens fileName for writing at a given offset. - // Returns nil if the file cannot be opened. - OpenFileWriterAt(fileName string, offset int64) OutputStream - - // OpenFileURI returns the SAF URI string for the file named fileName, - // or an empty string if the file cannot be resolved. - OpenFileURI(fileName string) string - - // RenamePartialFile renames the file at oldPath (a SAF URI) - // into the directory identified by newPath (a tree URI), - // giving it the new targetName. Returns the SAF URI of the renamed file, - // or an empty string if the operation failed. - RenamePartialFile(oldPath string, newPath string, targetName string) string - - // ListPartialFilesJSON returns a JSON-encoded list of partial filenames - // (e.g., ["foo.partial", "bar.partial"]) that match the given suffix. - ListPartialFilesJSON(suffix string) string - - // OpenPartialFileReader opens the file with the given name (typically a .partial file) + // OpenFileWriter creates or truncates a file named fileName at a given offset, + // returning an OutputStream for writing. Returns an error if the file cannot be opened. + OpenFileWriter(fileName string, offset int64) (stream OutputStream, err error) + + // GetFileURI returns the SAF URI string for the file named fileName, + // or an error if the file cannot be resolved. + GetFileURI(fileName string) (uri string, err error) + + // RenameFile renames the file at oldPath (a SAF URI) into the Taildrop directory, + // giving it the new targetName. Returns the SAF URI of the renamed file, or an error. + RenameFile(oldPath string, targetName string) (newURI string, err error) + + // ListFilesJSON returns a JSON-encoded list of filenames in the Taildrop directory + // that end with the specified suffix. If the suffix is empty, it returns all files. + // Returns an error if no matching files are found or the directory cannot be accessed. + ListFilesJSON(suffix string) (json string, err error) + + // OpenFileReader opens the file with the given name (typically a .partial file) // and returns an InputStream for reading its contents. - // Returns nil if the file cannot be opened. - OpenPartialFileReader(name string) InputStream + // Returns an error if the file cannot be opened. + OpenFileReader(name string) (stream InputStream, err error) // DeleteFile deletes the file identified by the given SAF URI string. // Returns an error if the file could not be deleted. - DeleteFile(uriString string) error - - // TreeURI returns the SAF tree URI representing the root directory for Taildrop files. - // This is typically the URI granted by the user via the Android directory picker. - TreeURI() string + DeleteFile(uri string) error - // GetFileInfo returns a JSON-encoded string with file metadata for fileName. - // Returns an empty string if the file does not exist or cannot be accessed. - GetFileInfo(fileName string) string + // GetFileInfo returns a JSON-encoded string containing metadata for fileName, + // matching the fields of androidFileInfo (name, size, modTime). + // Returns an error if the file does not exist or cannot be accessed. + GetFileInfo(fileName string) (json string, err error) } // The below are global callbacks that allow the Java application to notify Go diff --git a/libtailscale/streamutil.go b/libtailscale/streamutil.go index 13c3685..a656923 100644 --- a/libtailscale/streamutil.go +++ b/libtailscale/streamutil.go @@ -8,7 +8,7 @@ import ( "log" ) -// adaptInputStream wraps a libtailscale.InputStream into an io.ReadCloser. +// adaptInputStream wraps an [InputStream] into an [io.ReadCloser]. // It launches a goroutine to stream reads into a pipe. func adaptInputStream(in InputStream) io.ReadCloser { if in == nil { @@ -20,12 +20,16 @@ func adaptInputStream(in InputStream) io.ReadCloser { for { b, err := in.Read() if err != nil { - log.Printf("error reading from inputstream: %s", err) + log.Printf("error reading from inputstream: %v", err) + return } if b == nil { return } - w.Write(b) + if _, err := w.Write(b); err != nil { + log.Printf("error writing to pipe: %v", err) + return + } } }() return r