feat: add Files tab backed by the managed-files endpoint (#727)
* feat: add Files tab backed by the managed-files endpoint Add a Hermes Mobile tab that browses the gateway's managed-files root via the existing /api/files* backend routes: list/navigate folders, breadcrumb path, create folder, upload (streaming multipart), delete, and open files via the authenticated GatewayFileClient download URL. - data/model/ManagedFile: entry + list/read/upload/mkdir/delete models - HermesApiService: list/read/upload(+stream)/mkdir/delete endpoints - FilesViewModel: state, navigation, per-row busy, transient-state cleanup - FilesScreen: folder list, breadcrumb, FAB new folder, upload picker, delete confirm, open via system viewer - NavigationKeys + ScreenRegistry + strings: register in Inspect drawer section * fix(files): open files in-app via FileProvider instead of browser URL The previous file-open path handed a ?token=-authenticated URL to the system browser, but /api/files/download only accepts the ephemeral in-process session token as ?token=, not the mobile's Bearer token — so the request failed and the browser rendered the JSON error body instead of the file. Fix: download the file in-app through the authenticated client (Bearer/ session-cookie, same path as the rest of the app), decode the base64 data_url, write it to cacheDir, and open it via FileProvider.getUriForFile + ACTION_VIEW (mirrors the existing ChatScreen/SystemViewModel open-from-cache pattern). Works on a remote phone since the file is materialized locally first. --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
439ae9fcba
commit
6aee02c4da
8 files changed
+1024
No files matched your search
@@ -0,0 +1,96 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A single entry returned by `GET /api/files` — a file or directory living
|
||||
* under the gateway's managed-files root.
|
||||
*
|
||||
* Mirrors the backend `_managed_file_entry()` dict exactly
|
||||
* (hermes_cli/web_server.py). `mtime` is an epoch float on the wire; the
|
||||
* mobile model declares it `Double?` so a fractional value does not throw a
|
||||
* kotlinx decoding error (see dashboard-tab-to-android pitfalls: numeric
|
||||
* type drift).
|
||||
*/
|
||||
@Serializable
|
||||
data class ManagedFileEntry(
|
||||
val name: String = "",
|
||||
val path: String = "",
|
||||
@SerialName("is_directory")
|
||||
val isDirectory: Boolean = false,
|
||||
val size: Long? = null,
|
||||
val mtime: Double? = null,
|
||||
@SerialName("mime_type")
|
||||
val mimeType: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Response of `GET /api/files` (directory listing with navigation metadata).
|
||||
*
|
||||
* `parent` is `null` at the managed root (or when a locked root hides the
|
||||
* parent). `entries` is already server-sorted directories-first, then
|
||||
* case-insensitive name.
|
||||
*/
|
||||
@Serializable
|
||||
data class ManagedFilesListResponse(
|
||||
val path: String = "",
|
||||
val parent: String? = null,
|
||||
val entries: List<ManagedFileEntry> = emptyList(),
|
||||
val root: String? = null,
|
||||
@SerialName("locked_root")
|
||||
val lockedRoot: String? = null,
|
||||
@SerialName("can_change_path")
|
||||
val canChangePath: Boolean = false,
|
||||
)
|
||||
|
||||
/** Response of `GET /api/files/read` — base64 data URL of a file's bytes. */
|
||||
@Serializable
|
||||
data class ManagedFileRead(
|
||||
val name: String = "",
|
||||
val path: String = "",
|
||||
val size: Long = 0,
|
||||
@SerialName("mime_type")
|
||||
val mimeType: String = "application/octet-stream",
|
||||
@SerialName("data_url")
|
||||
val dataUrl: String = "",
|
||||
val root: String? = null,
|
||||
@SerialName("locked_root")
|
||||
val lockedRoot: String? = null,
|
||||
@SerialName("can_change_path")
|
||||
val canChangePath: Boolean = false,
|
||||
)
|
||||
|
||||
/** Generic `{"ok": true}`-style acknowledgement used by upload/mkdir/delete. */
|
||||
@Serializable
|
||||
data class ManagedFileActionResponse(
|
||||
val ok: Boolean = false,
|
||||
val path: String? = null,
|
||||
val entry: ManagedFileEntry? = null,
|
||||
val root: String? = null,
|
||||
@SerialName("locked_root")
|
||||
val lockedRoot: String? = null,
|
||||
@SerialName("can_change_path")
|
||||
val canChangePath: Boolean = false,
|
||||
)
|
||||
|
||||
/** Request body for `POST /api/files/upload` (base64 data URL form). */
|
||||
@Serializable
|
||||
data class ManagedFileUpload(
|
||||
val path: String,
|
||||
val data_url: String,
|
||||
val overwrite: Boolean = false,
|
||||
)
|
||||
|
||||
/** Request body for `POST /api/files/mkdir`. */
|
||||
@Serializable
|
||||
data class ManagedDirectoryCreate(
|
||||
val path: String,
|
||||
)
|
||||
|
||||
/** Request body for `DELETE /api/files`. */
|
||||
@Serializable
|
||||
data class ManagedFileDelete(
|
||||
val path: String,
|
||||
val recursive: Boolean = false,
|
||||
)
|
||||
@@ -35,6 +35,12 @@ import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
||||
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
|
||||
import com.m57.hermescontrol.data.model.KanbanTask
|
||||
import com.m57.hermescontrol.data.model.LogResponse
|
||||
import com.m57.hermescontrol.data.model.ManagedDirectoryCreate
|
||||
import com.m57.hermescontrol.data.model.ManagedFileActionResponse
|
||||
import com.m57.hermescontrol.data.model.ManagedFileDelete
|
||||
import com.m57.hermescontrol.data.model.ManagedFileRead
|
||||
import com.m57.hermescontrol.data.model.ManagedFileUpload
|
||||
import com.m57.hermescontrol.data.model.ManagedFilesListResponse
|
||||
import com.m57.hermescontrol.data.model.McpCatalogInstallRequest
|
||||
import com.m57.hermescontrol.data.model.McpCatalogResponse
|
||||
import com.m57.hermescontrol.data.model.McpServer
|
||||
@@ -106,9 +112,11 @@ import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.HTTP
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
@@ -757,4 +765,41 @@ interface HermesApiService {
|
||||
@Path("name") name: String,
|
||||
@Query("lines") lines: Int = 200,
|
||||
): Response<ActionStatusResponse>
|
||||
|
||||
// ── Managed Files ──────────────────────────────────────────────────
|
||||
// Backed by hermes_cli/web_server.py /api/files* endpoints. The mobile app
|
||||
// talks to the dashboard (9119) and authenticates with the standard
|
||||
// Bearer/session-cookie middleware, so no ?token= query param is needed.
|
||||
@GET("api/files")
|
||||
suspend fun listManagedFiles(
|
||||
@Query("path") path: String? = null,
|
||||
): Response<ManagedFilesListResponse>
|
||||
|
||||
@GET("api/files/read")
|
||||
suspend fun readManagedFile(
|
||||
@Query("path") path: String,
|
||||
): Response<ManagedFileRead>
|
||||
|
||||
@POST("api/files/upload")
|
||||
suspend fun uploadManagedFile(
|
||||
@Body body: ManagedFileUpload,
|
||||
): Response<ManagedFileActionResponse>
|
||||
|
||||
@Multipart
|
||||
@POST("api/files/upload-stream")
|
||||
suspend fun uploadManagedFileStream(
|
||||
@Part("path") path: okhttp3.RequestBody,
|
||||
@Part("overwrite") overwrite: okhttp3.RequestBody,
|
||||
@Part file: okhttp3.MultipartBody.Part,
|
||||
): Response<ManagedFileActionResponse>
|
||||
|
||||
@POST("api/files/mkdir")
|
||||
suspend fun createManagedDirectory(
|
||||
@Body body: ManagedDirectoryCreate,
|
||||
): Response<ManagedFileActionResponse>
|
||||
|
||||
@HTTP(method = "DELETE", path = "api/files", hasBody = true)
|
||||
suspend fun deleteManagedFile(
|
||||
@Body body: ManagedFileDelete,
|
||||
): Response<ManagedFileActionResponse>
|
||||
}
|
||||
Reference in new issue
Block a user