diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt index 8befc74..4305051 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt @@ -9,6 +9,7 @@ import com.tailscale.ipn.ui.model.Errors import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnState +import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.util.InputStreamAdapter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -18,6 +19,8 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.serializer +import libtailscale.FilePart +import java.io.File import java.nio.charset.Charset import kotlin.reflect.KType import kotlin.reflect.typeOf @@ -113,6 +116,34 @@ class Client(private val scope: CoroutineScope) { get(Endpoint.TKA_STATUS, responseHandler = responseHandler) } + fun putTaildropFiles( + peerId: StableNodeID, + files: Collection, + responseHandler: (Result) -> Unit + ) { + val manifest = Json.encodeToString(files.map { it.name to it.length() }.toMap()) + val manifestPart = FilePart() + manifestPart.body = InputStreamAdapter(manifest.byteInputStream(Charset.defaultCharset())) + manifestPart.filename = "manifest.json" + manifestPart.contentType = "application/json" + manifestPart.contentLength = manifest.length.toLong() + val parts = mutableListOf(manifestPart) + parts.addAll( + files.map { file -> + val part = FilePart() + part.filename = file.name + part.contentLength = file.length() + part.body = InputStreamAdapter(file.inputStream()) + part + }) + + return postMultipart( + "${Endpoint.FILE_PUT}/${peerId}", + FileParts(parts), + responseHandler, + ) + } + private inline fun get( path: String, body: ByteArray? = null, @@ -158,6 +189,22 @@ class Client(private val scope: CoroutineScope) { .execute() } + private inline fun postMultipart( + path: String, + parts: FileParts, + noinline responseHandler: (Result) -> Unit + ) { + Request( + scope = scope, + method = "POST", + path = path, + parts = parts, + timeoutMillis = 24 * 60 * 60 * 1000, // 24 hours + responseType = typeOf(), + responseHandler = responseHandler) + .execute() + } + private inline fun patch( path: String, body: ByteArray? = null, @@ -187,11 +234,12 @@ class Client(private val scope: CoroutineScope) { } } -class Request( +public class Request( private val scope: CoroutineScope, private val method: String, path: String, private val body: ByteArray? = null, + private val parts: FileParts? = null, private val timeoutMillis: Long = 30000, private val responseType: KType, private val responseHandler: (Result) -> Unit @@ -217,13 +265,16 @@ class Request( Log.d(TAG, "Executing request:${method}:${fullPath} on app $app") try { val resp = - app.callLocalAPI( - timeoutMillis, method, fullPath, body?.let { InputStreamAdapter(it.inputStream()) }) + if (parts != null) app.callLocalAPIMultipart(timeoutMillis, method, fullPath, parts) + else + app.callLocalAPI( + timeoutMillis, + method, + fullPath, + body?.let { InputStreamAdapter(it.inputStream()) }) // TODO: use the streaming body for performance - Log.d(TAG, "Got Response") // An empty body is a perfectly valid response and indicates success val respData = resp.bodyBytes() ?: ByteArray(0) - Log.d(TAG, "Got response body") val response: Result = when (responseType) { typeOf() -> Result.success(respData.decodeToString() as T) @@ -258,3 +309,13 @@ class Request( } } } + +class FileParts(private val parts: List) : libtailscale.FileParts { + override fun get(i: Int): FilePart { + return parts[i] + } + + override fun len(): Int { + return parts.size + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt index 7d4bba9..f95647f 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt @@ -32,6 +32,7 @@ class Ipn { val ErrMessage: String? = null, val LoginFinished: Empty.Message? = null, val FilesWaiting: Empty.Message? = null, + val OutgoingFiles: List? = null, val State: Int? = null, var Prefs: Prefs? = null, var NetMap: Netmap.NetworkMap? = null, @@ -154,6 +155,19 @@ class Ipn { var FinalPath: String? = null, val Done: Boolean? = null, ) + + @Serializable + data class OutgoingFile( + val Name: String, + val PeerID: StableNodeID, + val Started: String, + val DeclaredSize: Long, + val Sent: Long, + val PartialPath: String? = null, + var FinalPath: String? = null, + val Finished: Boolean, + val Succeeded: Boolean, + ) } class Persist { diff --git a/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt index 63522ed..c16b505 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt @@ -40,6 +40,7 @@ object Notifier { val vpnPermissionGranted: StateFlow = MutableStateFlow(null) val tileReady: StateFlow = MutableStateFlow(false) val readyToPrepareVPN: StateFlow = MutableStateFlow(false) + val outgoingFiles: StateFlow?> = MutableStateFlow(null) private lateinit var app: libtailscale.Application private var manager: libtailscale.NotificationManager? = null @@ -68,12 +69,12 @@ object Notifier { notify.BrowseToURL?.let(browseToURL::set) notify.LoginFinished?.let { loginFinished.set(it.property) } notify.Version?.let(version::set) + notify.OutgoingFiles?.let(outgoingFiles::set) } state.collect { currstate -> readyToPrepareVPN.set(currstate > Ipn.State.Stopped) tileReady.set(currstate >= Ipn.State.Stopped) } - Log.d(TAG, "Stopped") } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/InputStreamAdapter.kt b/android/src/main/java/com/tailscale/ipn/ui/util/InputStreamAdapter.kt index 798f4b0..6d5d08d 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/InputStreamAdapter.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/InputStreamAdapter.kt @@ -12,7 +12,7 @@ class InputStreamAdapter(private val inputStream: InputStream) : libtailscale.In if (i == -1) { return null } - return b.sliceArray(0..i) + return b.sliceArray(0 ..< i) } override fun close() { diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt b/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt index cc589c8..a3796b1 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString @@ -37,7 +38,8 @@ import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModelFactory fun PeerDetails( nav: BackNavigation, nodeId: String, - model: PeerDetailsViewModel = viewModel(factory = PeerDetailsViewModelFactory(nodeId)) + model: PeerDetailsViewModel = + viewModel(factory = PeerDetailsViewModelFactory(nodeId, LocalContext.current.filesDir)) ) { Scaffold(topBar = { Header(title = R.string.peer_details, onBack = nav.onBack) }) { innerPadding -> diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt index d91c427..a41b05b 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt @@ -13,16 +13,18 @@ import com.tailscale.ipn.ui.theme.ts_color_light_green import com.tailscale.ipn.ui.util.ComposableStringFormatter import com.tailscale.ipn.ui.util.DisplayAddress import com.tailscale.ipn.ui.util.TimeUtil +import java.io.File data class PeerSettingInfo(val titleRes: Int, val value: ComposableStringFormatter) -class PeerDetailsViewModelFactory(private val nodeId: StableNodeID) : ViewModelProvider.Factory { +class PeerDetailsViewModelFactory(private val nodeId: StableNodeID, private val filesDir: File) : + ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return PeerDetailsViewModel(nodeId) as T + return PeerDetailsViewModel(nodeId, filesDir) as T } } -class PeerDetailsViewModel(val nodeId: StableNodeID) : IpnViewModel() { +class PeerDetailsViewModel(val nodeId: StableNodeID, val filesDir: File) : IpnViewModel() { var addresses: List = emptyList() var info: List = emptyList() diff --git a/go.mod b/go.mod index 3123a70..5b1cc57 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( golang.org/x/mobile v0.0.0-20240319015410-c58ccf4b0c87 golang.org/x/sys v0.18.0 inet.af/netaddr v0.0.0-20220617031823-097006376321 - tailscale.com v1.63.0-pre.0.20240319225125-6da1dc84de57 + tailscale.com v1.63.0-pre.0.20240324181545-f78928191539 ) require ( diff --git a/go.sum b/go.sum index 065fcb2..6c27070 100644 --- a/go.sum +++ b/go.sum @@ -666,5 +666,5 @@ sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= 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= sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU= -tailscale.com v1.63.0-pre.0.20240319225125-6da1dc84de57 h1:AkU/jPJff3JoDr3gbM8Aut/GK7zriPTOYL7IY99YTXc= -tailscale.com v1.63.0-pre.0.20240319225125-6da1dc84de57/go.mod h1:cC0b0vYCoSDOLufJX5J5zDUrvV3lYwOLqlt9NW8y4cY= +tailscale.com v1.63.0-pre.0.20240324181545-f78928191539 h1:DBpvudmMiWINWePS9RdY+dST7xOideFf26TSgqsu2rQ= +tailscale.com v1.63.0-pre.0.20240324181545-f78928191539/go.mod h1:cC0b0vYCoSDOLufJX5J5zDUrvV3lYwOLqlt9NW8y4cY= diff --git a/libtailscale/interfaces.go b/libtailscale/interfaces.go index dbf1464..3a6185e 100644 --- a/libtailscale/interfaces.go +++ b/libtailscale/interfaces.go @@ -85,6 +85,10 @@ type Application interface { // without having to call over the network. CallLocalAPI(timeoutMillis int, method, endpoint string, body InputStream) (LocalAPIResponse, error) + // CallLocalAPIMultipart is like CallLocalAPI, but instead of a single body, + // it accepts multiple FileParts that get encoded as multipart/form-data. + CallLocalAPIMultipart(timeoutMillis int, method, endpoint string, parts FileParts) (LocalAPIResponse, error) + // WatchNotifications provides a mechanism for subscribing to ipn.Notify // updates. The given NotificationCallback's OnNotify function is invoked // on every new ipn.Notify message. The returned NotificationManager @@ -92,6 +96,20 @@ type Application interface { WatchNotifications(mask int, cb NotificationCallback) NotificationManager } +// FileParts is an array of multiple FileParts. +type FileParts interface { + Len() int32 + Get(int32) *FilePart +} + +// FilePart is a multipart file that can be submitted via CallLocalAPIMultiPart. +type FilePart struct { + ContentLength int64 + Filename string + Body InputStream + ContentType string // optional MIME content type +} + // LocalAPIResponse is a response to a localapi call, analogous to an http.Response. type LocalAPIResponse interface { StatusCode() int diff --git a/libtailscale/localapi.go b/libtailscale/localapi.go index 6435eaa..974c8e5 100644 --- a/libtailscale/localapi.go +++ b/libtailscale/localapi.go @@ -8,9 +8,14 @@ import ( "fmt" "io" "log" + "maps" + "mime/multipart" "net" "net/http" + "net/textproto" "runtime/debug" + "strconv" + "strings" "sync" "time" ) @@ -22,9 +27,86 @@ import ( // Note - Response includes a response body available from the Body method, it // is the caller's responsibility to close this. func (app *App) CallLocalAPI(timeoutMillis int, method, endpoint string, body InputStream) (LocalAPIResponse, error) { + return app.callLocalAPI(timeoutMillis, method, endpoint, nil, adaptInputStream(body)) +} + +// CallLocalAPIMultipart is like CallLocalAPI, but instead of uploading a +// generic body, it uploads a multipart/form-encoded body consisting of the +// supplied parts. +func (app *App) CallLocalAPIMultipart(timeoutMillis int, method, endpoint string, parts FileParts) (LocalAPIResponse, error) { + defer func() { + if p := recover(); p != nil { + log.Printf("panic in CallLocalAPIMultipart %s: %s", p, debug.Stack()) + panic(p) + } + }() + + r, w := io.Pipe() + defer r.Close() + + mw := multipart.NewWriter(w) + header := make(http.Header) + header.Set("Content-Type", mw.FormDataContentType()) + resultCh := make(chan interface{}) + go func() { + resp, err := app.callLocalAPI(timeoutMillis, method, endpoint, header, r) + if err != nil { + resultCh <- err + } else { + resultCh <- resp + } + }() + + go func() { + for i := int32(0); i < parts.Len(); i++ { + part := parts.Get(i) + contentType := "application/octet-stream" + if part.ContentType != "" { + contentType = part.ContentType + } + header := make(textproto.MIMEHeader, 3) + header.Set("Content-Disposition", + fmt.Sprintf(`form-data; name="%s"; filename="%s"`, + escapeQuotes("file"), escapeQuotes(part.Filename))) + header.Set("Content-Type", contentType) + header.Set("Content-Length", strconv.FormatInt(part.ContentLength, 10)) + p, err := mw.CreatePart(header) + if err != nil { + resultCh <- fmt.Errorf("CreatePart: %w", err) + return + } + _, err = io.Copy(p, adaptInputStream(part.Body)) + if err != nil { + resultCh <- fmt.Errorf("Copy: %w", err) + return + } + } + + err := mw.Close() + if err != nil { + resultCh <- fmt.Errorf("Close MultipartWriter: %w", err) + } + err = w.Close() + if err != nil { + resultCh <- fmt.Errorf("Close Writer: %w", err) + } + }() + + result := <-resultCh + switch t := result.(type) { + case LocalAPIResponse: + return t, nil + case error: + return nil, t + default: + panic("unexpected result type, this shouldn't happen") + } +} + +func (app *App) callLocalAPI(timeoutMillis int, method, endpoint string, header http.Header, body io.ReadCloser) (LocalAPIResponse, error) { defer func() { if p := recover(); p != nil { - log.Printf("panic in CallLocalAPI %s: %s", p, debug.Stack()) + log.Printf("panic in callLocalAPI %s: %s", p, debug.Stack()) panic(p) } }() @@ -38,7 +120,8 @@ func (app *App) CallLocalAPI(timeoutMillis int, method, endpoint string, body In defer body.Close() } - req, err := http.NewRequestWithContext(ctx, method, endpoint, adaptInputStream(body)) + req, err := http.NewRequestWithContext(ctx, method, endpoint, body) + maps.Copy(req.Header, header) if err != nil { return nil, fmt.Errorf("error creating new request for %s: %w", endpoint, err) } @@ -63,9 +146,9 @@ func (app *App) CallLocalAPI(timeoutMillis int, method, endpoint string, body In } }() + defer pipeWriter.Close() app.localAPIHandler.ServeHTTP(resp, req) resp.Flush() - pipeWriter.Close() }() select { @@ -127,7 +210,7 @@ func (r *Response) Flush() { }) } -func adaptInputStream(in InputStream) io.Reader { +func adaptInputStream(in InputStream) io.ReadCloser { if in == nil { return nil } @@ -147,3 +230,10 @@ func adaptInputStream(in InputStream) io.Reader { }() return r } + +// Below taken from Go stdlib +var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"") + +func escapeQuotes(s string) string { + return quoteEscaper.Replace(s) +}