diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt b/android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt index 9e73a42837..2a9c2b2098 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt @@ -24,3 +24,4 @@ class OutputStreamAdapter(private val outputStream: OutputStream) : libtailscale outputStream.close() } } + diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt index 64d4bd4d9c..8abfc943ad 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt @@ -65,7 +65,7 @@ fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = vi val capabilityIsOwner = "https://tailscale.com/cap/is-owner" val isOwner = netmapState?.hasCap(capabilityIsOwner) == true - Scaffold( + Scaffold( topBar = { Header( R.string.accounts, 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 fed568d095..a15636bf22 100644 --- a/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt +++ b/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt @@ -5,9 +5,14 @@ package com.tailscale.ipn.util import android.content.Context import android.net.Uri +import android.os.ParcelFileDescriptor +import android.provider.DocumentsContract import androidx.documentfile.provider.DocumentFile +import com.tailscale.ipn.ui.util.InputStreamAdapter import com.tailscale.ipn.ui.util.OutputStreamAdapter import libtailscale.Libtailscale +import org.json.JSONObject +import java.io.FileOutputStream import java.io.IOException import java.io.OutputStream import java.util.UUID @@ -29,100 +34,169 @@ object ShareFileHelper : libtailscale.ShareFileHelper { // A simple data class that holds a SAF OutputStream along with its URI. data class SafStream(val uri: String, val stream: OutputStream) - // Cache for streams; keyed by file name and savedUri. - private val streamCache = ConcurrentHashMap() - - // A helper function that creates (or reuses) a SafStream for a given file. - private fun createStreamCached(fileName: String): SafStream { - val key = "$fileName|$savedUri" - return streamCache.getOrPut(key) { - val context: Context = - appContext - ?: run { - TSLog.e("ShareFileHelper", "appContext is null, cannot create file: $fileName") - return SafStream("", OutputStream.nullOutputStream()) - } - val directoryUriString = - savedUri - ?: run { - TSLog.e("ShareFileHelper", "savedUri is null, cannot create file: $fileName") - return SafStream("", OutputStream.nullOutputStream()) - } - val dirUri = Uri.parse(directoryUriString) - val pickedDir: DocumentFile = - DocumentFile.fromTreeUri(context, dirUri) - ?: run { - TSLog.e("ShareFileHelper", "Could not access directory for URI: $dirUri") - return SafStream("", OutputStream.nullOutputStream()) - } - val newFile: DocumentFile = - pickedDir.createFile("application/octet-stream", fileName) - ?: run { - TSLog.e("ShareFileHelper", "Failed to create file: $fileName in directory: $dirUri") - return SafStream("", OutputStream.nullOutputStream()) - } - // Attempt to open an OutputStream for writing. - val os: OutputStream? = context.contentResolver.openOutputStream(newFile.uri) - if (os == null) { - TSLog.e("ShareFileHelper", "openOutputStream returned null for URI: ${newFile.uri}") - SafStream(newFile.uri.toString(), OutputStream.nullOutputStream()) - } else { - TSLog.d("ShareFileHelper", "Opened OutputStream for file: $fileName") - SafStream(newFile.uri.toString(), os) - } - } + // A helper function that opens or creates a SafStream for a given file. + private fun openSafFileOutputStream(fileName: String): Pair { + val context = appContext ?: return "" to null + val dirUri = savedUri ?: return "" to null + val dir = DocumentFile.fromTreeUri(context, Uri.parse(dirUri)) ?: return "" to null + + val file = + dir.findFile(fileName) + ?: dir.createFile("application/octet-stream", fileName) + ?: return "" to null + + val os = context.contentResolver.openOutputStream(file.uri, "rw") + return file.uri.toString() to os } - // This method returns a SafStream containing the SAF URI and its corresponding OutputStream. - override fun openFileWriter(fileName: String): libtailscale.OutputStream { - val stream = createStreamCached(fileName) - return OutputStreamAdapter(stream.stream) + @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) + ?: throw IOException("Failed to create file: $fileName") + + 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) } - override fun openFileURI(fileName: String): String { - val safFile = createStreamCached(fileName) - return safFile.uri + private val currentUri = ConcurrentHashMap() + + @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) } - override fun renamePartialFile( - partialUri: String, - targetDirUri: String, - targetName: String - ): String { + @Throws(IOException::class) + override fun getFileURI(fileName: String): String { + currentUri[fileName]?.let { + return it + } + + 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 + } + + @Throws(IOException::class) + override fun renameFile(oldPath: String, targetName: String): String { + val ctx = appContext ?: throw IOException("not initialized") + val dirUri = savedUri ?: throw IOException("directory not set") + val srcUri = Uri.parse(oldPath) + val dir = + 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() + } else { + finalName = generateNewFilename(finalName) + } + } + try { - val context = appContext ?: throw IllegalStateException("appContext is null") - val partialUriObj = Uri.parse(partialUri) - val targetDirUriObj = Uri.parse(targetDirUri) - val targetDir = - DocumentFile.fromTreeUri(context, targetDirUriObj) - ?: throw IllegalStateException( - "Unable to get target directory from URI: $targetDirUri") - var finalTargetName = targetName - - var destFile = targetDir.findFile(finalTargetName) - if (destFile != null) { - finalTargetName = generateNewFilename(finalTargetName) + DocumentsContract.renameDocument(ctx.contentResolver, srcUri, finalName)?.also { newUri -> + runCatching { ctx.contentResolver.delete(srcUri, null, null) } + cleanupPartials(dir, targetName) + return newUri.toString() } + } catch (e: Exception) { + TSLog.w("renameFile", "renameDocument fallback triggered for $srcUri -> $finalName: ${e.message}") - destFile = - targetDir.createFile("application/octet-stream", finalTargetName) - ?: throw IOException("Failed to create new file with name: $finalTargetName") + } + + 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) { + dest.delete() + throw IOException("Unable to open output stream for URI: ${dest.uri}") + } + inp.copyTo(out) + } + } + + ctx.contentResolver.delete(srcUri, null, null) + cleanupPartials(dir, targetName) + return dest.uri.toString() + } - context.contentResolver.openInputStream(partialUriObj)?.use { input -> - context.contentResolver.openOutputStream(destFile.uri)?.use { output -> - input.copyTo(output) - } ?: throw IOException("Unable to open output stream for URI: ${destFile.uri}") - } ?: throw IOException("Unable to open input stream for URI: $partialUri") + private fun lengthOfUri(ctx: Context, uri: Uri): Long = + ctx.contentResolver.openAssetFileDescriptor(uri, "r").use { it?.length ?: -1 } - DocumentFile.fromSingleUri(context, partialUriObj)?.delete() - return destFile.uri.toString() - } catch (e: Exception) { - throw IOException( - "Failed to rename partial file from URI $partialUri to final file in $targetDirUri with name $targetName: ${e.message}", - e) + // delete any stray “.partial” files for this base name + private fun cleanupPartials(dir: DocumentFile, base: String) { + for (child in dir.listFiles()) { + val n = child.name ?: continue + if (n.endsWith(".partial") && n.contains(base, ignoreCase = false)) { + child.delete() + } } } + @Throws(IOException::class) + override fun deleteFile(uri: String) { + val ctx = appContext ?: throw IOException("DeleteFile: not initialized") + + val uri = Uri.parse(uri) + val doc = + DocumentFile.fromSingleUri(ctx, uri) + ?: throw IOException("DeleteFile: cannot resolve URI $uri") + + if (!doc.delete()) { + throw IOException("DeleteFile: delete() returned false for $uri") + } + } + + @Throws(IOException::class) + override fun getFileInfo(fileName: String): String { + 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) ?: throw IOException("file \"$fileName\" not found in SAF directory") + + val name = file.name ?: throw IOException("file name missing for $fileName") + val size = file.length() + val modTime = file.lastModified() + + return """{"name":${JSONObject.quote(name)},"size":$size,"modTime":$modTime}""" + } + + private fun jsonEscape(s: String): String { + return JSONObject.quote(s) + } + fun generateNewFilename(filename: String): String { val dotIndex = filename.lastIndexOf('.') val baseName = if (dotIndex != -1) filename.substring(0, dotIndex) else filename @@ -131,4 +205,78 @@ object ShareFileHelper : libtailscale.ShareFileHelper { val uuid = UUID.randomUUID() return "$baseName-$uuid$extension" } + + fun listPartialFiles(suffix: String): Array { + val context = appContext ?: return emptyArray() + val rootUri = savedUri ?: return emptyArray() + val dir = DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) ?: return emptyArray() + + return dir.listFiles() + .filter { it.name?.endsWith(suffix) == true } + .mapNotNull { it.name } + .toTypedArray() + } + + @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 = "\"]") + } + + @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") + + val suffix = name.substringAfterLast('.', ".$name") + + val file = + dir.listFiles().firstOrNull { + val fname = it.name ?: return@firstOrNull false + fname.endsWith(suffix, ignoreCase = false) + } ?: throw IOException("no file ending with \"$suffix\" in SAF directory") + + val inStream = + context.contentResolver.openInputStream(file.uri) + ?: throw IOException("openInputStream returned null for ${file.uri}") + + return InputStreamAdapter(inStream) + } + + private class SeekableOutputStream( + private val fos: FileOutputStream, + private val pfd: ParcelFileDescriptor + ) : OutputStream() { + + private var closed = false + + override fun write(b: Int) = 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 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() + } } diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 5cefe14bd9..97d7edc514 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -136,7 +136,6 @@ Invalid key Custom control server URL Auth key - Delete tailnet Contact support All requests related to the removal or deletion of data are handled by our Support team. To open a request, tap the Contact Support button below to be taken to our contact form in the browser. Complete the form, and a Customer Support Engineer will work with you directly to assist. diff --git a/go.mod b/go.mod index 0d9c9358fc..05da9e2521 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.24.4 require ( 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.20250722205428-729d6532ff35 + tailscale.com v1.87.0-pre.0.20250801224156-0f15e4419683 ) require ( diff --git a/go.sum b/go.sum index 1e565875b5..11b45be40c 100644 --- a/go.sum +++ b/go.sum @@ -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.20250722205428-729d6532ff35 h1:RaZ9EcaONTkfAerz5hbjpFbtok9uqB46I34Q9T7VGQg= -tailscale.com v1.85.0-pre.0.20250722205428-729d6532ff35/go.mod h1:Lm8dnzU2i/Emw15r6sl3FRNp/liSQ/nYw6ZSQvIdZ1M= +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= \ No newline at end of file diff --git a/libtailscale/backend.go b/libtailscale/backend.go index c8d5f4ad79..bb1704ddc2 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 241097c6b4..769cd91a4a 100644 --- a/libtailscale/fileops.go +++ b/libtailscale/fileops.go @@ -3,36 +3,98 @@ package libtailscale import ( - "fmt" + "encoding/json" "io" + "os" + "time" + + "tailscale.com/feature/taildrop" ) -// AndroidFileOps implements the ShareFileHelper interface using the Android helper. -type AndroidFileOps struct { +// androidFileOps implements [taildrop.FileOps] using the Android ShareFileHelper. +type androidFileOps struct { helper ShareFileHelper } -func NewAndroidFileOps(helper ShareFileHelper) *AndroidFileOps { - return &AndroidFileOps{helper: helper} +var _ taildrop.FileOps = (*androidFileOps)(nil) + +func newAndroidFileOps(helper ShareFileHelper) *androidFileOps { + return &androidFileOps{helper: helper} +} + +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 + } + uri, err := ops.helper.GetFileURI(name) + if err != nil { + wc.Close() + return nil, "", err + } + return wc, uri, nil +} + +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) { + return ops.helper.RenameFile(oldPath, newName) } -func (ops *AndroidFileOps) OpenFileURI(filename string) string { - return ops.helper.OpenFileURI(filename) +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 + } + return names, nil } -func (ops *AndroidFileOps) OpenFileWriter(filename string) (io.WriteCloser, string, error) { - uri := ops.helper.OpenFileURI(filename) - outputStream := ops.helper.OpenFileWriter(filename) - if outputStream == nil { - return nil, uri, fmt.Errorf("failed to open SAF output stream for %s", filename) +func (ops *androidFileOps) OpenReader(name string) (io.ReadCloser, error) { + in, err := ops.helper.OpenFileReader(name) + if err != nil { + return nil, err } - return outputStream, uri, nil + return adaptInputStream(in), nil } -func (ops *AndroidFileOps) RenamePartialFile(partialUri, targetDirUri, targetName string) (string, error) { - newURI := ops.helper.RenamePartialFile(partialUri, targetDirUri, targetName) - if newURI == "" { - return "", fmt.Errorf("failed to rename partial file via SAF") +func (ops *androidFileOps) Stat(name string) (os.FileInfo, error) { + infoJSON, err := ops.helper.GetFileInfo(name) + if err != nil { + return nil, err } - return newURI, nil + var fi androidFileInfo + if err := json.Unmarshal([]byte(infoJSON), &fi); err != nil { + return nil, err + } + return &fi, nil +} + +type androidFileInfoJSON struct { + Name string `json:"name"` + Size int64 `json:"size"` + ModTime int64 `json:"modTime"` +} + +type androidFileInfo struct { + data androidFileInfoJSON } + +// 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 time.UnixMilli(fi.data.ModTime) } +func (fi *androidFileInfo) IsDir() bool { return false } +func (fi *androidFileInfo) Sys() any { return nil } diff --git a/libtailscale/interfaces.go b/libtailscale/interfaces.go index 44b9616d88..67a108cfeb 100644 --- a/libtailscale/interfaces.go +++ b/libtailscale/interfaces.go @@ -175,14 +175,36 @@ type OutputStream interface { // ShareFileHelper corresponds to the Kotlin ShareFileHelper class type ShareFileHelper interface { - OpenFileWriter(fileName string) OutputStream - - // OpenFileURI opens the file and returns its SAF URI. - OpenFileURI(filename string) string - - // RenamePartialFile takes SAF URIs and a target file name, - // and returns the new SAF URI and an error. - RenamePartialFile(partialUri string, targetDirUri string, targetName string) string + // 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 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(uri string) error + + // 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/localapi.go b/libtailscale/localapi.go index 678d44c33b..d25312bfc8 100644 --- a/libtailscale/localapi.go +++ b/libtailscale/localapi.go @@ -230,27 +230,6 @@ func (r *Response) Flush() { }) } -func adaptInputStream(in InputStream) io.ReadCloser { - if in == nil { - return nil - } - r, w := io.Pipe() - go func() { - defer w.Close() - for { - b, err := in.Read() - if err != nil { - log.Printf("error reading from inputstream: %s", err) - } - if b == nil { - return - } - w.Write(b) - } - }() - return r -} - // Below taken from Go stdlib var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"") diff --git a/libtailscale/streamutil.go b/libtailscale/streamutil.go new file mode 100644 index 0000000000..a656923277 --- /dev/null +++ b/libtailscale/streamutil.go @@ -0,0 +1,36 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package libtailscale + +import ( + "io" + "log" +) + +// 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 { + return nil + } + r, w := io.Pipe() + go func() { + defer w.Close() + for { + b, err := in.Read() + if err != nil { + log.Printf("error reading from inputstream: %v", err) + return + } + if b == nil { + return + } + if _, err := w.Write(b); err != nil { + log.Printf("error writing to pipe: %v", err) + return + } + } + }() + return r +}