Files
SoulStream/crates/tauri-plugin-storage/src/lib.rs
T
KeelrLeek defd8c5e6d android: a playlist file, picked
The storage plugin grew `pick_file` beside `pick_folder` and `pick_image`:
the document sheet with the caller's own filter, answered with a path Rust
can open. A document already on this phone hands its path over as it lies —
the document sibling of the same volume:relative id the folder picker
resolves, plus the Downloads provider's raw: — so a 300 MB pick copies
nothing; one that has no path is copied into cacheDir/picked under its own
name, and only as far as the caller allows, because a playlist is kilobytes
and a DJ set is not. Those copies are swept at the start of every pick,
which pick_image's never were.

The import sheet has its fifth door, on round 24's screen, wait card and
review, and the note that apologised for its absence says the one true thing
about it instead. `playlist_import_probe` took a path and a name the way
`playlist_import_folder` took a path, and the name is the load-bearing half:
the parser picks its format off the extension and titles the draft from the
stem, so a cache copy under an invented name would have read every playlist
as plain text. The mock walk drives the door and both of the picker's
refusals through round 31's fail door; the phone walk opens the real sheet
and dismisses it with BACK, reading nothing and writing nothing.

The brief asked for a second consumer — sending a file in a chat — and there
is nothing behind it: `community_pick_file` is the persona layer's three
native dialogs, `soulseek-client` has `say_room` and `message_user` and
nothing that sends a file, and both frontends' thread composers are text
only. Nothing was invented for it. The plugin command is general enough — a
caller's own filter, a zero-copy path, a bounded copy — that the one reader
still waiting, the local set listener the phone puts off by design, needs no
further plugin change. Playlist export is not on that list and never was:
writing a document out is the share plugin's errand, not a picker's. The
Kotlin is unbuilt — nothing on this machine compiles it.
2026-09-14 02:55:56 -05:00

274 lines
9.3 KiB
Rust

//! Storage on the phone.
//!
//! The library on Android lives in the app's private folder unless the user
//! points it somewhere else. Pointing it somewhere else needs two things the
//! WebView cannot do: a folder picker (Android's document tree picker, whose
//! answer is a content URI — resolved here to a real path when the volume
//! allows, which the Rust scanner and the download pipeline work on), and
//! for a folder in shared storage, Android's all-files access, which only
//! the user can grant on a system screen.
//!
//! Five commands: `pick_folder` → `{ uri, path }` (path null when the tree
//! is not on a volume with a path), `all_files_access` → `{ granted }`,
//! `request_all_files_access`, which opens the system screen, `pick_image`
//! → `{ path }` (a photo, copied into the app's cache), and `pick_file` →
//! `{ path, name, size, copied }` — the document picker with the caller's
//! own MIME filter.
//!
//! `pick_file` copies as little as it can, because an image is small and an
//! audio file is not. A document already on this phone's storage hands its
//! own path over as it lies, so picking a 300 MB file costs nothing; one
//! with no path of its own is copied into `cacheDir/picked` under its own
//! display name, and only as far as `maxCopyBytes` allows — zero refuses
//! the copy outright. Those copies are swept at the start of every pick, so
//! a path from one pick is gone by the next: read it in the same breath.
//! The display name travels beside the path because it is load-bearing —
//! the playlist parser picks its format off the extension and titles the
//! draft from the stem.
//!
//! On the desktop the plugin is registered so the permission set resolves,
//! and every command answers "not here".
use serde::{Deserialize, Serialize};
use tauri::{
plugin::{Builder, TauriPlugin},
Runtime,
};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct PickedFolder {
pub uri: String,
pub path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct AccessState {
pub granted: bool,
}
/// What a pick offers and what it may copy. `mime_types` is the document
/// sheet's own filter, comma-separated; empty offers every type.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct PickFileArgs {
pub mime_types: Option<String>,
/// How big a document may be COPIED into the app's cache when it has no
/// path of its own on this phone. Zero refuses the copy.
pub max_copy_bytes: u32,
}
/// A picked document. `path` is the document's own when this phone has one
/// for it, else the cache copy's (`copied`); `name` is the display name the
/// provider gave it, which is what decides a playlist's format and title.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct PickedFile {
pub path: Option<String>,
#[serde(default)]
pub name: String,
#[serde(default)]
pub size: Option<u64>,
#[serde(default)]
pub copied: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct PickedImage {
pub path: Option<String>,
}
#[cfg(mobile)]
pub struct Storage<R: Runtime>(tauri::plugin::PluginHandle<R>);
#[tauri::command]
async fn pick_folder<R: Runtime>(app: tauri::AppHandle<R>) -> Result<PickedFolder, String> {
#[cfg(mobile)]
{
use tauri::Manager;
let storage = app.state::<Storage<R>>();
storage
.0
.run_mobile_plugin::<PickedFolder>("pickFolder", ())
.map_err(|e| e.to_string())
}
#[cfg(not(mobile))]
{
let _ = app;
Err("the folder picker here is the desktop's dialog".into())
}
}
#[tauri::command]
fn all_files_access<R: Runtime>(app: tauri::AppHandle<R>) -> Result<AccessState, String> {
#[cfg(mobile)]
{
use tauri::Manager;
let storage = app.state::<Storage<R>>();
storage
.0
.run_mobile_plugin::<AccessState>("allFilesAccess", ())
.map_err(|e| e.to_string())
}
#[cfg(not(mobile))]
{
let _ = app;
Ok(AccessState { granted: true })
}
}
#[tauri::command]
fn request_all_files_access<R: Runtime>(app: tauri::AppHandle<R>) -> Result<(), String> {
#[cfg(mobile)]
{
use tauri::Manager;
let storage = app.state::<Storage<R>>();
storage
.0
.run_mobile_plugin::<serde_json::Value>("requestAllFilesAccess", ())
.map(|_| ())
.map_err(|e| e.to_string())
}
#[cfg(not(mobile))]
{
let _ = app;
Ok(())
}
}
/// A FILE from the phone: the document picker with the caller's own MIME
/// filter, answered with a path the Rust side can open. A document already
/// on this phone's storage is handed over as it lies; one that has no path
/// is copied into the app's cache under its own name, and only as far as
/// `max_copy_bytes` allows — zero refuses the copy, which is what a pick
/// that could be hundreds of megabytes wants.
#[tauri::command]
async fn pick_file<R: Runtime>(
app: tauri::AppHandle<R>,
mime_types: Option<String>,
max_copy_bytes: Option<u32>,
) -> Result<PickedFile, String> {
#[cfg(mobile)]
{
use tauri::Manager;
let storage = app.state::<Storage<R>>();
storage
.0
.run_mobile_plugin::<PickedFile>(
"pickFile",
PickFileArgs {
mime_types,
max_copy_bytes: max_copy_bytes.unwrap_or(0),
},
)
.map_err(|e| e.to_string())
}
#[cfg(not(mobile))]
{
let _ = (app, mime_types, max_copy_bytes);
Err("the file picker here is the desktop's dialog".into())
}
}
/// A photo from the phone: the document picker limited to images, the
/// picked content copied into the app's cache — a plain path for the Rust
/// side (a playlist's cover, for one).
#[tauri::command]
async fn pick_image<R: Runtime>(app: tauri::AppHandle<R>) -> Result<PickedImage, String> {
#[cfg(mobile)]
{
use tauri::Manager;
let storage = app.state::<Storage<R>>();
storage
.0
.run_mobile_plugin::<PickedImage>("pickImage", ())
.map_err(|e| e.to_string())
}
#[cfg(not(mobile))]
{
let _ = app;
Err("the image picker here is the desktop's dialog".into())
}
}
/// What Android's create-document sheet answered: where the file went, and
/// the name it actually got (the sheet lets the user change it on the way
/// past). Both absent when the sheet was dismissed — a cancel is not an
/// error here, because the export it serves answers a cancel with None.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct CreatedDocument {
pub uri: Option<String>,
pub name: Option<String>,
}
/// What `createDocument` is handed, as the Kotlin `@InvokeArg` names it.
/// Borrowed: the file can be long and nothing here keeps a copy.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)] // only the phone ever sends one; the desktop has a dialog
struct CreateDocumentArgs<'a> {
name: &'a str,
mime: &'a str,
text: &'a str,
}
/// A text file written wherever the user points: Android's create-document
/// sheet (ACTION_CREATE_DOCUMENT), and the Kotlin side writing the bytes
/// through the ContentResolver. That last part is the whole reason this
/// exists — the sheet answers with a `content://` URI, and `std::fs` has
/// never been able to open one, which is why the playlist export had no
/// door on the phone at all.
///
/// Deliberately NOT a `#[tauri::command]`: the caller is Rust, so the file
/// is built where the desktop builds it and the bytes never pass through
/// the WebView. Nothing is added to the ACL, and there is no JS wrapper to
/// keep in step with.
///
/// A dismissed sheet is not an error — it answers with both fields empty.
pub fn create_document<R: Runtime>(
app: &tauri::AppHandle<R>,
name: &str,
mime: &str,
text: &str,
) -> Result<CreatedDocument, String> {
#[cfg(mobile)]
{
use tauri::Manager;
let storage = app.state::<Storage<R>>();
storage
.0
.run_mobile_plugin::<CreatedDocument>(
"createDocument",
CreateDocumentArgs { name, mime, text },
)
.map_err(|e| e.to_string())
}
#[cfg(not(mobile))]
{
let _ = (app, name, mime, text);
Err("the create-document sheet is the phone's; here a save dialog picks the path".into())
}
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("storage")
.invoke_handler(tauri::generate_handler![pick_folder, all_files_access, request_all_files_access, pick_image, pick_file])
.setup(|app, api| {
#[cfg(target_os = "android")]
{
use tauri::Manager;
let handle = api.register_android_plugin("com.soulstream.storage", "StoragePlugin")?;
app.manage(Storage(handle));
}
#[cfg(not(target_os = "android"))]
{
let _ = (app, api);
}
Ok(())
})
.build()
}