From 28c19e632a2fc1a515b9f09ed9fff1551b88a47e Mon Sep 17 00:00:00 2001 From: Tryanks Date: Tue, 29 Sep 2026 05:54:36 +0800 Subject: [PATCH] feat(mobile): attach images from the phone's photo library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phones and tablets get a "+" at the left of the composer row that opens the system picker (PHPicker on iOS; the photo picker on Android 13+, the document picker below). Selected images are fitted on the device — long edge capped at 2048 px, non-wire formats and HEIC converted — before they go to the machine. Sending is gated by how the device reaches the machine: LAN and local are unrestricted; over a punched internet path an image above the device's "Attachment limit over the internet" setting (2 MiB default) asks first; over a relay it is refused, since the relay is shared and the main stream would stall behind it. Also fixed on the way: the client outgoing queue capped a line at 8 MiB while the wire allows 16 MiB and an attachment can encode to ~13 MiB, so images over about 6 MiB were rejected as "queue full" over any remote connection. The line cap now has one owner in tcode-protocol. The host also validates saved attachments: below its attachments root, no traversal, a plain extension, and at most the per-image limit. --- .../java/com/tryanks/tcode/GpuiActivity.java | 102 +++++ crates/android/src/host.rs | 106 ++++- crates/android/src/lib.rs | 64 ++- crates/client/src/host.rs | 27 ++ crates/client/src/outgoing.rs | 3 +- crates/core/src/attachments.rs | 24 +- crates/ios/host/Sources/BridgingHeader.h | 7 + .../ios/host/Sources/MobileHostServices.swift | 135 ++++++ crates/ios/src/host.rs | 88 +++- crates/protocol/src/lib.rs | 4 +- crates/protocol/src/wire.rs | 6 + crates/runtime/src/app/sessions.rs | 11 + crates/runtime/src/pipe.rs | 11 + crates/runtime/src/pipe_p4b_tests.rs | 23 +- crates/services/src/user_files.rs | 8 +- crates/traverse/src/native_host.rs | 34 +- crates/traverse/src/wire.rs | 2 +- crates/ui/src/attachments.rs | 74 ++++ crates/ui/src/composer/components/images.rs | 392 +++++++++++++++--- crates/ui/src/composer/components/pickers.rs | 59 +++ crates/ui/src/composer/mod.rs | 10 + crates/ui/src/settings_page.rs | 77 ++++ crates/ui/src/store/mod.rs | 38 ++ docs/remote.md | 13 +- locales/en.yml | 10 + locales/zh-CN.yml | 10 + 26 files changed, 1249 insertions(+), 89 deletions(-) diff --git a/crates/android/host/app/src/main/java/com/tryanks/tcode/GpuiActivity.java b/crates/android/host/app/src/main/java/com/tryanks/tcode/GpuiActivity.java index 9c60734b..e53eaf29 100644 --- a/crates/android/host/app/src/main/java/com/tryanks/tcode/GpuiActivity.java +++ b/crates/android/host/app/src/main/java/com/tryanks/tcode/GpuiActivity.java @@ -41,6 +41,7 @@ /** Minimal NativeActivity host for GPUI. */ public final class GpuiActivity extends NativeActivity { private static final int REQUEST_CAMERA = 6102; + private static final int REQUEST_IMAGES = 6103; private static final int HOST_OK = 0; private static final int HOST_CANCELLED = 1; private static final int HOST_ERROR = 2; @@ -53,6 +54,8 @@ public final class GpuiActivity extends NativeActivity { private boolean keyboardVisible; private boolean keyboardShowPending; private long cameraRequest; + private long imageRequest; + private int imageLimit; private ConnectivityManager.NetworkCallback networkCallback; private android.net.wifi.WifiManager.MulticastLock multicastLock; @@ -144,6 +147,8 @@ private native void nativeInputState(long revision, long serial, String text, private native void nativeOnInsets(int left, int top, int right, int bottom, int imeBottom); private native void nativeOnBack(boolean enabled); private native void nativeQrScanCompleted(long requestId, int status, String value); + private native void nativeImagePicked(long requestId, String name, String mime, byte[] bytes); + private native void nativeImagePickFinished(long requestId, int status, String error); private native void nativeNetworkChanged(); private native void nativeScrollCaptureSearch(long request); private native void nativeScrollCaptureStart(); @@ -418,9 +423,106 @@ public void gpuiStartCameraScan(long requestId) { } } + /** + * The system photo picker (Android 13+, no permission) or the document + * picker below it, limited to {@code limit} images. Results arrive through + * {@link #nativeImagePicked} per image and {@link #nativeImagePickFinished} + * once, in that order; a dismissed picker finishes with no images. + */ + public void gpuiPickImages(long requestId, int limit) { + if (imageRequest != 0) { + nativeImagePickFinished(requestId, HOST_ERROR, "图片选择正在进行"); + return; + } + imageRequest = requestId; + imageLimit = Math.max(1, limit); + try { + Intent intent; + if (Build.VERSION.SDK_INT >= 33) { + intent = new Intent(android.provider.MediaStore.ACTION_PICK_IMAGES); + if (limit > 1) { + int max = Math.min(limit, android.provider.MediaStore.getPickImagesMaxLimit()); + intent.putExtra(android.provider.MediaStore.EXTRA_PICK_IMAGES_MAX, max); + } + } else { + intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, limit > 1); + } + intent.setType("image/*"); + startActivityForResult(intent, REQUEST_IMAGES); + } catch (RuntimeException error) { + imageRequest = 0; + nativeImagePickFinished(requestId, HOST_ERROR, errorMessage(error)); + } + } + + private void deliverPickedImages(long request, int resultCode, Intent data, int limit) { + java.util.ArrayList uris = new java.util.ArrayList<>(); + if (resultCode == Activity.RESULT_OK && data != null) { + ClipData clip = data.getClipData(); + if (clip != null) { + for (int i = 0; i < clip.getItemCount() && uris.size() < limit; i++) { + Uri uri = clip.getItemAt(i).getUri(); + if (uri != null) uris.add(uri); + } + } else if (data.getData() != null) { + uris.add(data.getData()); + } + } + if (uris.isEmpty()) { + nativeImagePickFinished(request, HOST_CANCELLED, null); + return; + } + // Reading a content URI blocks; the picker's own thread is gone by now. + new Thread(() -> { + try { + for (Uri uri : uris) { + byte[] bytes = readAll(uri); + String mime = getContentResolver().getType(uri); + nativeImagePicked(request, displayName(uri), mime, bytes); + } + nativeImagePickFinished(request, HOST_OK, null); + } catch (Exception error) { + nativeImagePickFinished(request, HOST_ERROR, errorMessage(error)); + } + }, "tcode-image-pick").start(); + } + + private byte[] readAll(Uri uri) throws java.io.IOException { + try (java.io.InputStream input = getContentResolver().openInputStream(uri)) { + if (input == null) throw new java.io.IOException("cannot open " + uri); + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + byte[] buffer = new byte[64 * 1024]; + int read; + while ((read = input.read(buffer)) != -1) out.write(buffer, 0, read); + return out.toByteArray(); + } + } + + private String displayName(Uri uri) { + try (android.database.Cursor cursor = getContentResolver().query( + uri, new String[] {android.provider.OpenableColumns.DISPLAY_NAME}, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + String name = cursor.getString(0); + if (name != null && !name.isEmpty()) return name; + } + } catch (RuntimeException ignored) { + // Some providers refuse metadata queries; the segment below still names the file. + } + String segment = uri.getLastPathSegment(); + return segment == null || segment.isEmpty() ? "image" : segment; + } + @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); + if (requestCode == REQUEST_IMAGES) { + long request = imageRequest; + imageRequest = 0; + if (request != 0) deliverPickedImages(request, resultCode, data, imageLimit); + return; + } if (requestCode != REQUEST_CAMERA) { return; } diff --git a/crates/android/src/host.rs b/crates/android/src/host.rs index d2f8c675..2b67392a 100644 --- a/crates/android/src/host.rs +++ b/crates/android/src/host.rs @@ -19,7 +19,7 @@ use jni::{ objects::{JObject, JString, JValue}, refs::Global, }; -use tcode_client::host::HostFuture; +use tcode_client::host::{HostFuture, PickedImage}; use tcode_traverse::NativeClientHost; const RESULT_OK: i32 = 0; @@ -36,9 +36,25 @@ enum BridgeEvent { status: i32, value: Option, }, + /// One image the picker returned; more may follow before `ImagesPicked`. + ImagePicked { + request_id: u64, + image: PickedImage, + }, + ImagesPicked { + request_id: u64, + status: i32, + error: Option, + }, NetworkChanged, } +/// A picker request in flight: what arrived so far and who awaits it. +struct PickRequest { + images: Vec, + done: async_channel::Sender, String>>, +} + #[derive(Clone)] struct JniObject { vm: JavaVM, @@ -123,6 +139,27 @@ impl JavaBridge { })); } + fn pick_images(&self, request_id: u64, limit: usize) { + let object = self.object.clone(); + self.app.run_on_java_main_thread(Box::new(move || { + if let Err(error) = object.with_env(|env, activity| { + env.call_method( + activity, + jni_str!("gpuiPickImages"), + jni_sig!("(JI)V"), + &[ + JValue::Long(request_id as i64), + JValue::Int(i32::try_from(limit).unwrap_or(i32::MAX)), + ], + )?; + Ok(()) + }) { + log::error!("Android image picker JNI call failed: {error}"); + deliver_images_picked(request_id, 2, Some(error)); + } + })); + } + fn start_camera(&self, request_id: u64) { let object = self.object.clone(); self.app.run_on_java_main_thread(Box::new(move || { @@ -179,8 +216,11 @@ pub(crate) fn native_host( async_channel::Sender>, >::new())); let pending = callbacks.clone(); + let picks = Rc::new(RefCell::new(HashMap::::new())); + let pending_picks = picks.clone(); let multicast = bridge.object.clone(); let camera = bridge.clone(); + let picker = bridge.clone(); let host = NativeClientHost::new(data_dir, device_name) .with_platform(platform) .with_multicast_lock(move |acquire| { @@ -207,7 +247,27 @@ pub(crate) fn native_host( .await .unwrap_or_else(|error| Err(error.to_string())) }) - }); + }) + .with_image_picker( + move |limit| -> HostFuture<'static, Result, String>> { + let (done, receiver) = async_channel::bounded(1); + let request_id = NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed); + picks.borrow_mut().insert( + request_id, + PickRequest { + images: Vec::new(), + done, + }, + ); + picker.pick_images(request_id, limit); + Box::pin(async move { + receiver + .recv() + .await + .unwrap_or_else(|error| Err(error.to_string())) + }) + }, + ); let host = Rc::new(host); let (sender, mut receiver) = mpsc::unbounded(); @@ -216,9 +276,33 @@ pub(crate) fn native_host( cx.spawn(async move |cx| { while let Some(event) = receiver.next().await { let pending = pending.clone(); + let picks = pending_picks.clone(); let host = events_host.clone(); cx.update(move |_cx| match event { BridgeEvent::NetworkChanged => host.network_changed(), + BridgeEvent::ImagePicked { request_id, image } => { + if let Some(request) = picks.borrow_mut().get_mut(&request_id) { + request.images.push(image); + } else { + log::warn!("image for unknown Android picker request {request_id}"); + } + } + BridgeEvent::ImagesPicked { + request_id, + status, + error, + } => { + let request = picks.borrow_mut().remove(&request_id); + let Some(request) = request else { + log::warn!("result for unknown Android picker request {request_id}"); + return; + }; + let result = match status { + RESULT_OK | RESULT_CANCELLED => Ok(request.images), + _ => Err(error.unwrap_or_else(|| "Android image picker failed".into())), + }; + let _ = request.done.try_send(result); + } BridgeEvent::CameraResult { request_id, status, @@ -259,6 +343,24 @@ fn send_event(event: BridgeEvent, dropped: &str) { } } +pub(crate) fn deliver_image_picked(request_id: u64, image: PickedImage) { + send_event( + BridgeEvent::ImagePicked { request_id, image }, + "picked image", + ); +} + +pub(crate) fn deliver_images_picked(request_id: u64, status: i32, error: Option) { + send_event( + BridgeEvent::ImagesPicked { + request_id, + status, + error, + }, + "image picker result", + ); +} + pub(crate) fn deliver_result(request_id: u64, status: i32, value: Option) { send_event( BridgeEvent::CameraResult { diff --git a/crates/android/src/lib.rs b/crates/android/src/lib.rs index 8b0e7386..1bdde11f 100644 --- a/crates/android/src/lib.rs +++ b/crates/android/src/lib.rs @@ -126,10 +126,22 @@ mod jni_exports { use jni::{ EnvUnowned, errors::LogErrorAndDefault, - objects::{JObject, JString}, + objects::{JByteArray, JObject, JString}, sys::{jboolean, jint, jlong}, }; + fn optional_string<'local>( + env: &mut jni::Env<'local>, + value: JObject<'local>, + ) -> jni::errors::Result> { + if value.is_null() { + return Ok(None); + } + JString::cast_local(env, value) + .and_then(|value| value.try_to_string(env)) + .map(Some) + } + #[unsafe(no_mangle)] pub extern "system" fn Java_com_tryanks_tcode_GpuiActivity_nativeFirstFrameRendered( _env: EnvUnowned, @@ -296,6 +308,44 @@ mod jni_exports { crate::host::network_changed(); } + #[unsafe(no_mangle)] + pub extern "system" fn Java_com_tryanks_tcode_GpuiActivity_nativeImagePicked<'local>( + mut env: EnvUnowned<'local>, + _activity: JObject<'local>, + request_id: jlong, + name: JObject<'local>, + mime: JObject<'local>, + bytes: JByteArray<'local>, + ) { + env.with_env(|env| -> jni::errors::Result<()> { + let name = optional_string(env, name)?.unwrap_or_else(|| "image".into()); + let mime = optional_string(env, mime)?.unwrap_or_else(|| "image/jpeg".into()); + let bytes = env.convert_byte_array(&bytes)?; + crate::host::deliver_image_picked( + request_id as u64, + tcode_client::host::PickedImage { name, mime, bytes }, + ); + Ok(()) + }) + .resolve::() + } + + #[unsafe(no_mangle)] + pub extern "system" fn Java_com_tryanks_tcode_GpuiActivity_nativeImagePickFinished<'local>( + mut env: EnvUnowned<'local>, + _activity: JObject<'local>, + request_id: jlong, + status: jint, + error: JObject<'local>, + ) { + env.with_env(|env| -> jni::errors::Result<()> { + let error = optional_string(env, error)?; + crate::host::deliver_images_picked(request_id as u64, status, error); + Ok(()) + }) + .resolve::() + } + #[unsafe(no_mangle)] pub extern "system" fn Java_com_tryanks_tcode_GpuiActivity_nativeQrScanCompleted<'local>( mut env: EnvUnowned<'local>, @@ -305,14 +355,10 @@ mod jni_exports { value: JObject<'local>, ) { env.with_env(|env| -> jni::errors::Result<()> { - let value = if value.is_null() { - None - } else { - JString::cast_local(env, value) - .and_then(|value| value.try_to_string(env)) - .map_err(|error| log::error!("failed reading QR result: {error}")) - .ok() - }; + let value = optional_string(env, value) + .map_err(|error| log::error!("failed reading QR result: {error}")) + .ok() + .flatten(); crate::host::deliver_result(request_id as u64, status, value); Ok(()) }) diff --git a/crates/client/src/host.rs b/crates/client/src/host.rs index 24d8ab36..df355909 100644 --- a/crates/client/src/host.rs +++ b/crates/client/src/host.rs @@ -128,6 +128,19 @@ pub struct ClientPreferences { /// Opaque, client-local UI restoration state. The shell owns its schema. #[serde(default, skip_serializing_if = "Option::is_none")] pub navigation: Option, + /// Per-image ceiling, in MiB, this device applies to attachments it sends + /// across the internet; `None` is + /// [`tcode_core::attachments::DEFAULT_REMOTE_BYTES`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_attachment_limit_mib: Option, +} + +/// One image the platform's own picker handed back, already read into memory. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PickedImage { + pub name: String, + pub mime: String, + pub bytes: Vec, } /// Persistence, pairing, transport, and platform facilities for a tcode client. @@ -214,6 +227,20 @@ pub trait ClientHost: 'static { Box::pin(async { Err("unsupported".into()) }) } + /// Whether [`pick_images`](Self::pick_images) opens the platform's own + /// media picker. Desktops attach by paste and drop instead. + fn supports_image_picker(&self) -> bool { + false + } + + /// Let the user choose up to `limit` images in the platform's media + /// picker. Dismissing the picker resolves to an empty list; `Err` is a + /// real failure to show. + fn pick_images(&self, limit: usize) -> HostFuture<'_, Result, String>> { + let _ = limit; + Box::pin(async { Err("unsupported".into()) }) + } + /// Whether [`ClientHost::deliver_artifact`] can actually hand a produced /// file to the user here (a browser download, a share sheet). Views ask /// before offering the action, so a client without one shows Copy instead of diff --git a/crates/client/src/outgoing.rs b/crates/client/src/outgoing.rs index 7883524b..163e8c6a 100644 --- a/crates/client/src/outgoing.rs +++ b/crates/client/src/outgoing.rs @@ -8,7 +8,8 @@ use std::{ }; pub const MAX_LINES: usize = 256; -pub const MAX_BYTES: usize = 8 * 1024 * 1024; +/// The queue must admit one line of the protocol's maximum size on its own. +pub const MAX_BYTES: usize = tcode_protocol::MAX_LINE_BYTES; #[derive(Default)] struct Queue { diff --git a/crates/core/src/attachments.rs b/crates/core/src/attachments.rs index a589d2fa..5e236ef3 100644 --- a/crates/core/src/attachments.rs +++ b/crates/core/src/attachments.rs @@ -4,8 +4,21 @@ use std::path::Path; /// Maximum images per message. pub const MAX_IMAGES: usize = 8; -/// Maximum bytes per image (10 MiB). +/// Maximum bytes per image as stored on the host and sent to the model. pub const MAX_BYTES: u64 = 10 * 1024 * 1024; +/// Largest source file a client decodes before fitting it to the limits. +pub const MAX_SOURCE_BYTES: u64 = 64 * 1024 * 1024; +/// Longest edge a client keeps when fitting an image before upload; models +/// gain nothing from more pixels and phone photos start far larger. +pub const MAX_EDGE_PX: u32 = 2048; +/// Default per-image ceiling a device applies when it reaches the machine +/// across the internet (tunnel or relay). Adjustable on the device. +pub const DEFAULT_REMOTE_BYTES: u64 = 2 * 1024 * 1024; + +/// Whether `ext` is safe to append to a generated attachment file name. +pub fn is_safe_extension(ext: &str) -> bool { + !ext.is_empty() && ext.len() <= 8 && ext.bytes().all(|byte| byte.is_ascii_alphanumeric()) +} /// Best-effort MIME type from a file extension. pub fn mime_from_path(path: &Path) -> String { @@ -104,4 +117,13 @@ mod tests { ); } } + + #[test] + fn generated_file_names_take_only_plain_extensions() { + assert!(is_safe_extension("png")); + assert!(is_safe_extension("JPG")); + for ext in ["", "../x", "png/", "png.exe", "a-b", "toolongext"] { + assert!(!is_safe_extension(ext), "{ext:?}"); + } + } } diff --git a/crates/ios/host/Sources/BridgingHeader.h b/crates/ios/host/Sources/BridgingHeader.h index 59370d25..6477e34b 100644 --- a/crates/ios/host/Sources/BridgingHeader.h +++ b/crates/ios/host/Sources/BridgingHeader.h @@ -25,6 +25,13 @@ void tcode_ios_camera_scan_completed(uint64_t request_id, size_t value_length, const uint8_t *error_bytes, size_t error_length); +void tcode_ios_image_picked(uint64_t request_id, const uint8_t *name_bytes, + size_t name_length, const uint8_t *mime_bytes, + size_t mime_length, const uint8_t *data, + size_t data_length); +void tcode_ios_image_pick_finished(uint64_t request_id, + const uint8_t *error_bytes, + size_t error_length); void gpui_ios_init(void); uint8_t gpui_ios_attach_view(void *view, float width, float height, float scale, diff --git a/crates/ios/host/Sources/MobileHostServices.swift b/crates/ios/host/Sources/MobileHostServices.swift index 47fd9c73..ce6ae432 100644 --- a/crates/ios/host/Sources/MobileHostServices.swift +++ b/crates/ios/host/Sources/MobileHostServices.swift @@ -1,5 +1,7 @@ import AVFoundation +import PhotosUI import UIKit +import UniformTypeIdentifiers private struct MobileHostServiceError: LocalizedError { let message: String @@ -118,6 +120,139 @@ public func tcodeIosHostStartCameraScan(_ requestId: UInt64) { } } +/// Delegates of pickers on screen, keyed by request; a PHPicker does not +/// retain its delegate. +private var imagePickers: [UInt64: ImagePickerDelegate] = [:] + +@_cdecl("tcode_ios_host_pick_images") +public func tcodeIosHostPickImages(_ requestId: UInt64, _ limit: Int) { + guard let presenter = topPresenter() else { + finishImagePick(requestId, error: "无法显示相册") + return + } + var configuration = PHPickerConfiguration(photoLibrary: .shared()) + configuration.filter = .images + configuration.selectionLimit = max(1, limit) + // HEIC and other formats the machine cannot decode arrive as JPEG. + configuration.preferredAssetRepresentationMode = .compatible + let delegate = ImagePickerDelegate(requestId: requestId) + imagePickers[requestId] = delegate + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = delegate + presenter.present(picker, animated: true) +} + +private func finishImagePick(_ requestId: UInt64, error: String?) { + imagePickers[requestId] = nil + if let error { + withUTF8(error) { bytes, length in + tcode_ios_image_pick_finished(requestId, bytes, length) + } + } else { + tcode_ios_image_pick_finished(requestId, nil, 0) + } +} + +private final class ImagePickerDelegate: NSObject, PHPickerViewControllerDelegate { + private let requestId: UInt64 + /// Formats delivered as they are; anything else loads as a UIImage and + /// leaves as JPEG. + private static let passthrough: [(UTType, String, String)] = [ + (.png, "image/png", "png"), + (.jpeg, "image/jpeg", "jpg"), + (.gif, "image/gif", "gif"), + (.webP, "image/webp", "webp"), + ] + + init(requestId: UInt64) { + self.requestId = requestId + } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + let requestId = self.requestId + let providers = results.map(\.itemProvider) + // Loads complete on arbitrary queues; deliver in selection order from + // the main queue, as every other host callback does. + DispatchQueue.global(qos: .userInitiated).async { + var loaded: [(String, String, Data)] = [] + var failure: String? + for (index, provider) in providers.enumerated() { + let stem = provider.suggestedName ?? "photo-\(index + 1)" + switch Self.load(provider) { + case .success(let (mime, ext, data)): + loaded.append(("\(stem).\(ext)", mime, data)) + case .failure(let error): + failure = error.localizedDescription + } + } + DispatchQueue.main.async { + for (name, mime, data) in loaded { + withUTF8(name) { nameBytes, nameLength in + withUTF8(mime) { mimeBytes, mimeLength in + data.withUnsafeBytes { buffer in + tcode_ios_image_picked( + requestId, + nameBytes, + nameLength, + mimeBytes, + mimeLength, + buffer.bindMemory(to: UInt8.self).baseAddress, + buffer.count + ) + } + } + } + } + finishImagePick(requestId, error: loaded.isEmpty ? failure : nil) + } + } + } + + private static func load(_ provider: NSItemProvider) -> Result<(String, String, Data), Error> { + for (type, mime, ext) in passthrough + where provider.hasItemConformingToTypeIdentifier(type.identifier) { + return loadData(provider, type: type).map { (mime, ext, $0) } + } + return loadObject(provider).flatMap { image in + guard let data = image.jpegData(compressionQuality: 0.9) else { + return .failure(MobileHostServiceError("无法编码所选图片")) + } + return .success(("image/jpeg", "jpg", data)) + } + } + + private static func loadData(_ provider: NSItemProvider, type: UTType) -> Result { + let done = DispatchSemaphore(value: 0) + var result: Result = .failure(MobileHostServiceError("无法读取所选图片")) + provider.loadDataRepresentation(forTypeIdentifier: type.identifier) { data, error in + if let data { + result = .success(data) + } else if let error { + result = .failure(error) + } + done.signal() + } + done.wait() + return result + } + + private static func loadObject(_ provider: NSItemProvider) -> Result { + let done = DispatchSemaphore(value: 0) + var result: Result = .failure(MobileHostServiceError("无法读取所选图片")) + provider.loadObject(ofClass: UIImage.self) { object, error in + if let image = object as? UIImage { + result = .success(image) + } else if let error { + result = .failure(error) + } + done.signal() + } + done.wait() + return result + } +} + private func completeCamera(_ requestId: UInt64, result: Result) { switch result { case .success(let value): diff --git a/crates/ios/src/host.rs b/crates/ios/src/host.rs index bb3cd45b..fff0da3b 100644 --- a/crates/ios/src/host.rs +++ b/crates/ios/src/host.rs @@ -12,13 +12,20 @@ use std::{ }, }; -use tcode_client::host::HostFuture; +use tcode_client::host::{HostFuture, PickedImage}; use tcode_traverse::{NativeClientHost, lan::SystemBrowser}; type ScanDone = Box)>; +/// A photo-picker request in flight: what arrived so far and who awaits it. +struct PickRequest { + images: Vec, + done: async_channel::Sender, String>>, +} + thread_local! { static CAMERA_CALLBACKS: RefCell> = RefCell::new(HashMap::new()); + static PICK_REQUESTS: RefCell> = RefCell::new(HashMap::new()); /// The shell owns the host; scene callbacks reach it while it lives. static HOST: RefCell> = const { RefCell::new(Weak::new()) }; } @@ -34,6 +41,7 @@ unsafe extern "C" { fn tcode_ios_host_device_platform(destination: *mut u8, capacity: usize) -> usize; fn tcode_ios_host_system_locale(destination: *mut u8, capacity: usize) -> usize; fn tcode_ios_host_start_camera_scan(request_id: u64); + fn tcode_ios_host_pick_images(request_id: u64, limit: usize); fn tcode_ios_host_browse_start(request: u64); fn tcode_ios_host_browse_stop(request: u64); } @@ -100,7 +108,30 @@ pub(crate) fn native_host(cx: &mut gpui::App) -> (Rc, Option HostFuture<'static, Result, String>> { + let (done, receiver) = async_channel::bounded(1); + let request_id = NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed); + PICK_REQUESTS.with(|requests| { + requests.borrow_mut().insert( + request_id, + PickRequest { + images: Vec::new(), + done, + }, + ); + }); + // SAFETY: UIKit retains the id and finishes the request exactly once. + unsafe { tcode_ios_host_pick_images(request_id, limit) }; + Box::pin(async move { + receiver + .recv() + .await + .unwrap_or_else(|error| Err(error.to_string())) + }) + }, + ); let host = Rc::new(host); HOST.with(|slot| *slot.borrow_mut() = Rc::downgrade(&host)); (host, system_locale) @@ -171,6 +202,59 @@ pub extern "C" fn tcode_ios_camera_scan_completed( callback(result); } +/// One image the PHPicker loaded for request `request_id`; more may follow +/// before [`tcode_ios_image_pick_finished`]. Called on the main queue. +#[unsafe(no_mangle)] +pub extern "C" fn tcode_ios_image_picked( + request_id: u64, + name_bytes: *const u8, + name_length: usize, + mime_bytes: *const u8, + mime_length: usize, + data: *const u8, + data_length: usize, +) { + // SAFETY: Swift keeps all three temporary buffers alive through this call. + let name = unsafe { ffi_string(name_bytes, name_length) }.unwrap_or_else(|| "image".into()); + // SAFETY: same as above. + let mime = + unsafe { ffi_string(mime_bytes, mime_length) }.unwrap_or_else(|| "image/jpeg".into()); + let bytes = if data.is_null() || data_length == 0 { + Vec::new() + } else { + // SAFETY: same as above. + unsafe { slice::from_raw_parts(data, data_length) }.to_vec() + }; + PICK_REQUESTS.with(|requests| { + if let Some(request) = requests.borrow_mut().get_mut(&request_id) { + request.images.push(PickedImage { name, mime, bytes }); + } else { + log::warn!("image for unknown iOS picker request {request_id}"); + } + }); +} + +/// The PHPicker is done with request `request_id`: dismissed, or every +/// selected image was delivered, or it failed with `error`. +#[unsafe(no_mangle)] +pub extern "C" fn tcode_ios_image_pick_finished( + request_id: u64, + error_bytes: *const u8, + error_length: usize, +) { + let request = PICK_REQUESTS.with(|requests| requests.borrow_mut().remove(&request_id)); + let Some(request) = request else { + log::warn!("unknown iOS picker request {request_id}"); + return; + }; + // SAFETY: Swift keeps the temporary buffer alive through this call. + let error = unsafe { ffi_string(error_bytes, error_length) }; + let _ = request.done.try_send(match error { + Some(error) => Err(error), + None => Ok(request.images), + }); +} + fn read_native_string(read: impl Fn(*mut u8, usize) -> usize) -> Option { let length = read(ptr::null_mut(), 0); if length == 0 { diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 6a395c1b..d63298f2 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -30,8 +30,8 @@ pub use query::{ }; pub use terminal::{TerminalDelta, TerminalFrame}; pub use wire::{ - ClientMessage, ClientPayload, HostMessage, ProtocolError, Subscription, decode_client_line, - decode_host_line, encode_line, + ClientMessage, ClientPayload, HostMessage, MAX_LINE_BYTES, ProtocolError, Subscription, + decode_client_line, decode_host_line, encode_line, }; // Version 4 adds client-generated command deduplication keys; version 5 moves diff --git a/crates/protocol/src/wire.rs b/crates/protocol/src/wire.rs index 4fb85680..d2d06df4 100644 --- a/crates/protocol/src/wire.rs +++ b/crates/protocol/src/wire.rs @@ -2,6 +2,12 @@ use serde::{Deserialize, Serialize, de::DeserializeOwned}; use crate::{Command, CommandResponse, EventEnvelope, Query, QueryResponse, Topic}; +/// Bound on one encoded protocol line in either direction. Every transport +/// and every client queue admits a line up to this size, so the largest +/// single payload — one attachment of [`tcode_core::attachments::MAX_BYTES`] +/// bytes, base64-encoded — always fits. +pub const MAX_LINE_BYTES: usize = 16 * 1024 * 1024; + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ProtocolError { pub code: String, diff --git a/crates/runtime/src/app/sessions.rs b/crates/runtime/src/app/sessions.rs index daad0547..2cfa1879 100644 --- a/crates/runtime/src/app/sessions.rs +++ b/crates/runtime/src/app/sessions.rs @@ -573,6 +573,17 @@ impl AppState { user_files::attachment_dir(self.store.root(), session_id) } + /// Whether a client may save an attachment into `dir` with extension + /// `ext`: only a directory below this host's attachments root, named + /// without traversal, and only a plain extension for the generated name. + pub(crate) fn accepts_attachment(&self, dir: &Path, ext: &str) -> bool { + dir.starts_with(user_files::attachments_root(self.store.root())) + && !dir + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + && tcode_core::attachments::is_safe_extension(ext) + } + /// Persist attachment bytes to a previously captured active-session target. /// Callers run this blocking helper on the background executor. pub fn save_attachment_to_dir(dir: &Path, bytes: &[u8], ext: &str) -> std::io::Result { diff --git a/crates/runtime/src/pipe.rs b/crates/runtime/src/pipe.rs index a3788ca4..32d615b7 100644 --- a/crates/runtime/src/pipe.rs +++ b/crates/runtime/src/pipe.rs @@ -684,6 +684,17 @@ fn dispatch_query( }) } Query::SaveAttachment { dir, bytes, ext } => { + if bytes.len() as u64 > tcode_core::attachments::MAX_BYTES + || !app.accepts_attachment(&dir, &ext) + { + return cx.spawn_background(async { + Err(ProtocolError { + code: "invalid_attachment".into(), + message: "attachment too large or outside the host's attachments directory" + .into(), + }) + }); + } let task = cx.unblock(move || AppState::save_attachment_to_dir(&dir, &bytes, &ext)); cx.spawn_background(async move { task.await diff --git a/crates/runtime/src/pipe_p4b_tests.rs b/crates/runtime/src/pipe_p4b_tests.rs index 04f3bfd5..f8070e4c 100644 --- a/crates/runtime/src/pipe_p4b_tests.rs +++ b/crates/runtime/src/pipe_p4b_tests.rs @@ -179,8 +179,29 @@ fn attachments_mux_uses_host_session_directory_and_returns_identical_bytes() { assert_eq!(std::fs::read(&path).unwrap(), bytes); assert_eq!( smol::block_on(link.query(Query::ReadFileBytes { path })).unwrap(), - QueryResponse::FileBytes(bytes) + QueryResponse::FileBytes(bytes.clone()) ); + // A client cannot write outside the attachments root, escape it, name + // the file into another directory, or exceed the per-image limit. + let outside = dir.parent().unwrap().parent().unwrap().to_path_buf(); + for (dir, bytes, ext) in [ + (outside, bytes.clone(), "png".to_string()), + ( + dir.join("..").join("elsewhere"), + bytes.clone(), + "png".to_string(), + ), + (dir.clone(), bytes.clone(), "png/../../x".to_string()), + ( + dir.clone(), + vec![0; tcode_core::attachments::MAX_BYTES as usize + 1], + "png".to_string(), + ), + ] { + let error = smol::block_on(link.query(Query::SaveAttachment { dir, bytes, ext })) + .expect_err("rejected"); + assert_eq!(error.code, "invalid_attachment"); + } link.command_blocking(Command::ShutdownAllAndFlush).unwrap(); } diff --git a/crates/services/src/user_files.rs b/crates/services/src/user_files.rs index 209d8d3a..32d0a4fd 100644 --- a/crates/services/src/user_files.rs +++ b/crates/services/src/user_files.rs @@ -53,7 +53,13 @@ pub fn relativize_to_workspace(path: &str, cwd: &Path) -> String { /// Return the directory used to persist attachments for a session. pub fn attachment_dir(data_root: &Path, session_id: &str) -> PathBuf { - data_root.join("attachments").join(session_id) + attachments_root(data_root).join(session_id) +} + +/// The directory every session's attachment directory lives under; a client +/// may only save attachments below it. +pub fn attachments_root(data_root: &Path) -> PathBuf { + data_root.join("attachments") } /// Save plan markdown to the lowest unused numbered plan file in the workspace. diff --git a/crates/traverse/src/native_host.rs b/crates/traverse/src/native_host.rs index 4a8dccaa..d54a8771 100644 --- a/crates/traverse/src/native_host.rs +++ b/crates/traverse/src/native_host.rs @@ -7,7 +7,7 @@ use std::{ sync::{Arc, OnceLock}, }; -use tcode_client::host::{ClientHost, ClientPreferences, HostFuture, Transport}; +use tcode_client::host::{ClientHost, ClientPreferences, HostFuture, PickedImage, Transport}; use tcode_client::pairing::{PairInvite, PairedHost}; use crate::{ @@ -16,6 +16,7 @@ use crate::{ }; type QrScanner = dyn Fn() -> HostFuture<'static, Result>; +type ImagePicker = dyn Fn(usize) -> HostFuture<'static, Result, String>>; type EditorOpener = dyn Fn(&Path) -> Result<(), String>; type MulticastLock = dyn Fn(bool) + Send + Sync; @@ -26,6 +27,7 @@ pub struct NativeClientHost { default_device_name: String, platform: Option, qr_scanner: Option>, + image_picker: Option>, editor: Option>, multicast_lock: Option>, system_browser: Option>, @@ -41,6 +43,7 @@ impl NativeClientHost { default_device_name: device_name.into(), platform: default_device_platform(), qr_scanner: None, + image_picker: None, editor: None, multicast_lock: None, system_browser: None, @@ -118,6 +121,15 @@ impl NativeClientHost { self } + /// The platform's own media picker; see [`ClientHost::pick_images`]. + pub fn with_image_picker( + mut self, + picker: impl Fn(usize) -> HostFuture<'static, Result, String>> + 'static, + ) -> Self { + self.image_picker = Some(Box::new(picker)); + self + } + /// Held around every DNS-SD browse: Android delivers multicast to an /// app only while it holds the Wi-Fi multicast lock. Called from the /// transport's threads. @@ -200,6 +212,10 @@ impl ClientHost for NativeClientHost { .get("navigation") .filter(|value| !value.is_null()) .cloned(), + remote_attachment_limit_mib: prefs + .get("remote_attachment_limit_mib") + .and_then(serde_json::Value::as_u64) + .and_then(|limit| u32::try_from(limit).ok()), } } @@ -209,6 +225,8 @@ impl ClientHost for NativeClientHost { prefs["language"] = serde_json::json!(preferences.language); prefs["device_name"] = serde_json::json!(preferences.device_name); prefs["navigation"] = serde_json::json!(preferences.navigation); + prefs["remote_attachment_limit_mib"] = + serde_json::json!(preferences.remote_attachment_limit_mib); self.write_prefs(&prefs); } @@ -340,6 +358,17 @@ impl ClientHost for NativeClientHost { |scanner| scanner(), ) } + + fn supports_image_picker(&self) -> bool { + self.image_picker.is_some() + } + + fn pick_images(&self, limit: usize) -> HostFuture<'_, Result, String>> { + self.image_picker.as_ref().map_or_else( + || Box::pin(async { Err("unsupported".into()) }) as HostFuture<'_, _>, + |picker| picker(limit), + ) + } } /// This machine's name, shared by desktop, headless, and native-client defaults. @@ -740,6 +769,7 @@ mod tests { language: None, device_name: Some("Renamed".into()), navigation: Some(serde_json::json!({"history": ["hosts", "threads"]})), + remote_attachment_limit_mib: Some(4), }); let saved: serde_json::Value = @@ -752,6 +782,8 @@ mod tests { serde_json::json!(["hosts", "threads"]) ); assert!(saved["language"].is_null()); + assert_eq!(saved["remote_attachment_limit_mib"], 4); + assert_eq!(host.load_preferences().remote_attachment_limit_mib, Some(4)); host.set_last_host_id(Some("next-host")); assert_eq!(host.last_host_id().as_deref(), Some("next-host")); diff --git a/crates/traverse/src/wire.rs b/crates/traverse/src/wire.rs index 4a84856e..e7c30720 100644 --- a/crates/traverse/src/wire.rs +++ b/crates/traverse/src/wire.rs @@ -36,7 +36,7 @@ pub const MAX_CONTROL_LINE: usize = 4096; /// How long a peer has to send a stream's first line. pub const CONTROL_TIMEOUT: Duration = Duration::from_secs(5); /// Bound on one NDJSON line after hello, in either direction. -pub const MAX_LINE: usize = 16 * 1024 * 1024; +pub const MAX_LINE: usize = tcode_protocol::MAX_LINE_BYTES; /// Bi streams one connection may hold open at once. pub const MAX_STREAMS: u32 = 64; /// Preview tunnels one connection may hold open at once, leaving streams for diff --git a/crates/ui/src/attachments.rs b/crates/ui/src/attachments.rs index e9a451db..d7f32403 100644 --- a/crates/ui/src/attachments.rs +++ b/crates/ui/src/attachments.rs @@ -41,6 +41,41 @@ pub(crate) fn open_image_lightbox( }); } +/// How this client reaches the machine that stores the attachment. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransferLink { + /// The host runs in this process; nothing crosses a network. + Local, + Lan, + /// A direct path punched across the internet. + Tunnel, + /// Carried by a Traverse relay, which is shared and rate-limited. + Relay, + /// Remote, but the transport cannot say how (the browser client). + Unknown, +} + +/// What the composer does with an attachment of `size` bytes over `link`, +/// given the device's own ceiling for internet transfers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransferVerdict { + Send, + /// Over a punched path the user decides; the cost is only their time. + Confirm, + /// A relay never carries it: the path is shared with everyone else on + /// the service and the main stream would stall behind it. + Reject, +} + +pub fn transfer_verdict(link: TransferLink, size: u64, remote_limit: u64) -> TransferVerdict { + match link { + TransferLink::Local | TransferLink::Lan => TransferVerdict::Send, + _ if size <= remote_limit => TransferVerdict::Send, + TransferLink::Tunnel => TransferVerdict::Confirm, + TransferLink::Relay | TransferLink::Unknown => TransferVerdict::Reject, + } +} + pub(crate) fn attach_error_message(error: &AttachError) -> String { match error { AttachError::UnsupportedType { name } => { @@ -50,3 +85,42 @@ pub(crate) fn attach_error_message(error: &AttachError) -> String { AttachError::TooMany => crate::tr!("attach.too_many").into_owned(), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_internet_links_apply_the_remote_ceiling() { + let limit = 2 * 1024 * 1024; + for link in [TransferLink::Local, TransferLink::Lan] { + assert_eq!( + transfer_verdict(link, limit * 5, limit), + TransferVerdict::Send, + "{link:?}" + ); + } + for link in [ + TransferLink::Tunnel, + TransferLink::Relay, + TransferLink::Unknown, + ] { + assert_eq!( + transfer_verdict(link, limit, limit), + TransferVerdict::Send, + "{link:?}" + ); + } + assert_eq!( + transfer_verdict(TransferLink::Tunnel, limit + 1, limit), + TransferVerdict::Confirm + ); + for link in [TransferLink::Relay, TransferLink::Unknown] { + assert_eq!( + transfer_verdict(link, limit + 1, limit), + TransferVerdict::Reject, + "{link:?}" + ); + } + } +} diff --git a/crates/ui/src/composer/components/images.rs b/crates/ui/src/composer/components/images.rs index 91cdf9e6..f518683d 100644 --- a/crates/ui/src/composer/components/images.rs +++ b/crates/ui/src/composer/components/images.rs @@ -1,4 +1,9 @@ use super::super::*; +use crate::attachments::{TransferVerdict, transfer_verdict}; +use crate::thread_export::format_size; +use image::GenericImageView as _; +use std::cell::RefCell; +use tcode_core::attachments::{AttachError, MAX_EDGE_PX, MAX_IMAGES, MAX_SOURCE_BYTES}; #[derive(Clone)] /// A pending image attachment: validated, persisted to the session attachments @@ -15,10 +20,17 @@ pub(in super::super) enum PendingImageSource { } pub(in super::super) enum AddImageError { - Attachment(tcode_core::attachments::AttachError), + Attachment(AttachError), Persist(std::io::Error), } +/// Fitted bytes waiting for the host to store them. +struct PreparedImage { + dir: PathBuf, + bytes: Vec, + ext: String, +} + /// Guess a file extension for a persisted attachment from its MIME type, /// falling back to the source name's extension, then `png`. fn image_extension(mime: &str, name: &str) -> String { @@ -46,11 +58,39 @@ fn is_wire_ready_image(mime: &str) -> bool { ) } -pub(in super::super) fn transcode_image_to_png(bytes: &[u8]) -> image::ImageResult> { - let image = image::load_from_memory(bytes)?; - let mut png = std::io::Cursor::new(Vec::new()); - image.write_to(&mut png, image::ImageFormat::Png)?; - Ok(png.into_inner()) +/// Bring an image to what the wire and the model take: a format every +/// provider accepts, no longer than [`MAX_EDGE_PX`] on either side. Bytes +/// that already qualify pass through untouched so nothing is recompressed; +/// GIFs always do, since re-encoding keeps only their first frame. +pub(crate) fn fit_for_upload(mime: &str, bytes: Vec) -> image::ImageResult<(String, Vec)> { + if mime == "image/gif" { + return Ok((mime.to_string(), bytes)); + } + let decoded = image::load_from_memory(&bytes)?; + let (width, height) = decoded.dimensions(); + let oversized = width.max(height) > MAX_EDGE_PX; + if is_wire_ready_image(mime) && !oversized { + return Ok((mime.to_string(), bytes)); + } + let image = if oversized { + decoded.resize( + MAX_EDGE_PX, + MAX_EDGE_PX, + image::imageops::FilterType::Triangle, + ) + } else { + decoded + }; + let mut out = std::io::Cursor::new(Vec::new()); + // Photos stay JPEG; anything else becomes PNG, which keeps transparency. + if mime == "image/jpeg" { + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 85); + image.to_rgb8().write_with_encoder(encoder)?; + Ok(("image/jpeg".to_string(), out.into_inner())) + } else { + image.write_to(&mut out, image::ImageFormat::Png)?; + Ok(("image/png".to_string(), out.into_inner())) + } } impl Composer { @@ -66,8 +106,8 @@ impl Composer { } } - /// Validate, decode/transcode, and persist an image off the main thread. - /// Completion appends only while the same session still owns the strip. + /// Validate, fit, and persist an image off the main thread. Completion + /// appends only while the same session still owns the strip. pub(in super::super) fn add_image( &mut self, name: String, @@ -77,59 +117,49 @@ impl Composer { cx: &mut Context, ) -> bool { self.sync_images_session(cx); - let initial_size = match &source { - PendingImageSource::Bytes(bytes) => bytes.len() as u64, - PendingImageSource::Path(_) => 0, - }; - if let Err(err) = validate_attachment( - &name, - &mime, - initial_size, - self.pending_images.len() + self.pending_image_loads, - ) { + let current_count = self.pending_images.len() + self.pending_image_loads; + // Type and count are known now; the size is checked once the image is + // fitted, since a phone photo starts far larger than it is sent. + if let Err(err) = validate_attachment(&name, &mime, 0, current_count) { window.push_notification(Notification::error(attach_error_message(&err)), cx); return false; } - let session_id = self.workspace_store.read(cx).active_session_id(); - let attachments_dir = self - .workspace_store - .read(cx) - .composer_state() - .attachments_dir; - let current_count = self.pending_images.len() + self.pending_image_loads; + let store = self.workspace_store.read(cx); + let session_id = store.active_session_id(); + let attachments_dir = store.composer_state().attachments_dir; + let link = store.attachment_link(); + let remote_limit = store.client_remote_attachment_limit_bytes(); self.pending_image_loads += 1; let generation = self.image_load_generation; let result_name = name.clone(); - let workspace_store = self.workspace_store.clone(); cx.spawn_in(window, async move |this, cx| { let prepared = cx .background_executor() .spawn(async move { + let too_large = + || AddImageError::Attachment(AttachError::TooLarge { name: name.clone() }); let bytes = match source { PendingImageSource::Bytes(bytes) => bytes, PendingImageSource::Path(path) => { let size = std::fs::metadata(&path) .map_err(AddImageError::Persist)? .len(); - validate_attachment(&name, &mime, size, current_count) - .map_err(AddImageError::Attachment)?; + if size > MAX_SOURCE_BYTES { + return Err(too_large()); + } std::fs::read(path).map_err(AddImageError::Persist)? } }; + if bytes.len() as u64 > MAX_SOURCE_BYTES { + return Err(too_large()); + } + let (mime, bytes) = fit_for_upload(&mime, bytes).map_err(|_| { + AddImageError::Attachment(AttachError::UnsupportedType { + name: name.clone(), + }) + })?; validate_attachment(&name, &mime, bytes.len() as u64, current_count) .map_err(AddImageError::Attachment)?; - let (mime, bytes) = if is_wire_ready_image(&mime) { - (mime, bytes) - } else { - let bytes = transcode_image_to_png(&bytes).map_err(|_| { - AddImageError::Attachment( - tcode_core::attachments::AttachError::UnsupportedType { - name: name.clone(), - }, - ) - })?; - ("image/png".to_string(), bytes) - }; let dir = attachments_dir.ok_or_else(|| { AddImageError::Persist(std::io::Error::new( std::io::ErrorKind::NotFound, @@ -137,47 +167,210 @@ impl Composer { )) })?; let ext = image_extension(&mime, &name); - Ok::<_, AddImageError>((dir, bytes, ext)) + Ok::<_, AddImageError>(PreparedImage { dir, bytes, ext }) }) .await; - let result = match prepared { - Ok((dir, bytes, ext)) => workspace_store - .update(cx, |store, cx| { - store.save_attachment_to_dir(dir, bytes, ext, cx) - }) - .await - .map_err(AddImageError::Persist), - Err(error) => Err(error), - }; let _ = this.update_in(cx, |composer, window, cx| { - if composer.image_load_generation != generation - || composer.images_session != session_id - || composer.workspace_store.read(cx).active_session_id() != session_id - { + if !composer.owns_image_load(generation, &session_id, cx) { + return; + } + let prepared = match prepared { + Ok(prepared) => prepared, + Err(error) => { + composer.pending_image_loads = + composer.pending_image_loads.saturating_sub(1); + composer.notify_add_image_error(error, window, cx); + return; + } + }; + let size = prepared.bytes.len() as u64; + match transfer_verdict(link, size, remote_limit) { + TransferVerdict::Send => composer.persist_image( + session_id, + generation, + result_name, + prepared, + window, + cx, + ), + TransferVerdict::Reject => { + composer.pending_image_loads = + composer.pending_image_loads.saturating_sub(1); + window.push_notification( + Notification::error( + crate::tr!( + "attach.relay_rejected", + name = result_name, + size = format_size(size as usize), + limit = format_size(remote_limit as usize) + ) + .into_owned(), + ), + cx, + ); + } + TransferVerdict::Confirm => { + let composer_entity = cx.entity(); + // Taken by whichever button fires; a dialog closed + // by Escape or the backdrop releases the slot too. + let prepared = Rc::new(RefCell::new(Some(prepared))); + let session_id = session_id.clone(); + window.open_alert_dialog(cx, move |alert, _, cx| { + let composer = composer_entity.clone(); + let release_entity = composer_entity.clone(); + let prepared = prepared.clone(); + let dropped = prepared.clone(); + let session_id = session_id.clone(); + let result_name = result_name.clone(); + let release = move |cx: &mut App| { + if dropped.borrow_mut().take().is_some() { + release_entity.update(cx, |composer, _cx| { + composer.pending_image_loads = + composer.pending_image_loads.saturating_sub(1); + }); + } + }; + let on_cancel = release.clone(); + alert + .bg(cx.theme().popover) + .title(crate::tr!("attach.tunnel_title")) + .description(crate::tr!( + "attach.tunnel_body", + name = result_name, + size = format_size(size as usize) + )) + .button_props( + DialogButtons::default() + .ok_text(crate::tr!("attach.tunnel_confirm")) + .cancel_text(crate::tr!("mobile.cancel")) + .show_cancel(true), + ) + .on_ok(move |_, window, cx| { + if let Some(prepared) = prepared.borrow_mut().take() { + composer.update(cx, |composer, cx| { + composer.persist_image( + session_id.clone(), + generation, + result_name.clone(), + prepared, + window, + cx, + ); + }); + } + true + }) + .on_cancel(move |_, _, cx| { + on_cancel(cx); + true + }) + .on_close(move |_, _, cx| release(cx)) + }); + } + } + }); + }) + .detach(); + true + } + + /// Whether a load started under `generation` for `session_id` still + /// belongs to the strip on screen. + fn owns_image_load(&self, generation: u64, session_id: &Option, cx: &App) -> bool { + self.image_load_generation == generation + && &self.images_session == session_id + && &self.workspace_store.read(cx).active_session_id() == session_id + } + + fn notify_add_image_error( + &self, + error: AddImageError, + window: &mut Window, + cx: &mut Context, + ) { + let message = match error { + AddImageError::Attachment(err) => attach_error_message(&err), + AddImageError::Persist(err) => { + crate::tr!("errors.persist_event", error = err).into_owned() + } + }; + window.push_notification(Notification::error(message), cx); + } + + /// Send fitted bytes to the host and, once saved, show the thumbnail. + fn persist_image( + &mut self, + session_id: Option, + generation: u64, + name: String, + prepared: PreparedImage, + window: &mut Window, + cx: &mut Context, + ) { + let PreparedImage { dir, bytes, ext } = prepared; + let task = self.workspace_store.update(cx, |store, cx| { + store.save_attachment_to_dir(dir, bytes, ext, cx) + }); + cx.spawn_in(window, async move |this, cx| { + let result = task.await; + let _ = this.update_in(cx, |composer, window, cx| { + if !composer.owns_image_load(generation, &session_id, cx) { return; } composer.pending_image_loads = composer.pending_image_loads.saturating_sub(1); match result { Ok(path) => { - composer.pending_images.push(PendingImage { - path, - name: result_name, - }); + composer.pending_images.push(PendingImage { path, name }); cx.notify(); } - Err(AddImageError::Attachment(err)) => window - .push_notification(Notification::error(attach_error_message(&err)), cx), - Err(AddImageError::Persist(err)) => window.push_notification( - Notification::error( - crate::tr!("errors.persist_event", error = err).into_owned(), - ), - cx, - ), + Err(err) => { + composer.notify_add_image_error(AddImageError::Persist(err), window, cx) + } } }); }) .detach(); - true + } + + /// Open the platform's media picker (phones) and add what it returns. + pub(in super::super) fn pick_images_from_library( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + let Some(host) = cx + .try_global::() + .map(crate::remote::ClientAttachment::host) + else { + return; + }; + self.sync_images_session(cx); + let remaining = + MAX_IMAGES.saturating_sub(self.pending_images.len() + self.pending_image_loads); + if remaining == 0 { + window.push_notification( + Notification::error(attach_error_message(&AttachError::TooMany)), + cx, + ); + return; + } + cx.spawn_in(window, async move |this, cx| { + let picked = host.pick_images(remaining).await; + let _ = this.update_in(cx, |composer, window, cx| match picked { + Ok(images) => { + for image in images { + composer.add_image_bytes(image.name, image.mime, image.bytes, window, cx); + } + } + Err(error) => window.push_notification( + Notification::error( + crate::tr!("attach.pick_failed", error = error).into_owned(), + ), + cx, + ), + }); + }) + .detach(); } pub(in super::super) fn add_image_bytes( @@ -342,3 +535,68 @@ impl Composer { ); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn encoded(image: image::DynamicImage, format: image::ImageFormat) -> Vec { + let mut out = std::io::Cursor::new(Vec::new()); + image.write_to(&mut out, format).unwrap(); + out.into_inner() + } + + #[test] + fn fitting_bounds_the_long_edge_and_keeps_qualifying_bytes() { + let wide = image::DynamicImage::new_rgb8(MAX_EDGE_PX * 2, 100); + let (mime, bytes) = + fit_for_upload("image/png", encoded(wide, image::ImageFormat::Png)).unwrap(); + assert_eq!(mime, "image/png"); + assert_eq!( + image::load_from_memory(&bytes).unwrap().dimensions(), + (MAX_EDGE_PX, 50) + ); + + let photo = encoded( + image::DynamicImage::new_rgb8(MAX_EDGE_PX + 1, MAX_EDGE_PX + 1), + image::ImageFormat::Jpeg, + ); + let (mime, bytes) = fit_for_upload("image/jpeg", photo).unwrap(); + assert_eq!(mime, "image/jpeg"); + assert_eq!( + image::load_from_memory(&bytes).unwrap().dimensions(), + (MAX_EDGE_PX, MAX_EDGE_PX) + ); + + let small = encoded( + image::DynamicImage::new_rgba8(10, 10), + image::ImageFormat::Png, + ); + assert_eq!( + fit_for_upload("image/png", small.clone()).unwrap(), + ("image/png".to_string(), small) + ); + + let bmp = encoded( + image::DynamicImage::new_rgb8(10, 10), + image::ImageFormat::Bmp, + ); + let (mime, bytes) = fit_for_upload("image/bmp", bmp).unwrap(); + assert_eq!(mime, "image/png"); + assert_eq!( + image::guess_format(&bytes).unwrap(), + image::ImageFormat::Png + ); + + let gif = encoded( + image::DynamicImage::new_rgba8(MAX_EDGE_PX * 2, 10), + image::ImageFormat::Gif, + ); + assert_eq!( + fit_for_upload("image/gif", gif.clone()).unwrap(), + ("image/gif".to_string(), gif) + ); + + assert!(fit_for_upload("image/png", b"not an image".to_vec()).is_err()); + } +} diff --git a/crates/ui/src/composer/components/pickers.rs b/crates/ui/src/composer/components/pickers.rs index b5fd1602..c401f2b1 100644 --- a/crates/ui/src/composer/components/pickers.rs +++ b/crates/ui/src/composer/components/pickers.rs @@ -555,6 +555,65 @@ impl Composer { /// The "⋯" overflow button + popover holding the context / permission / /// mode controls when the control row is too narrow to show them inline. + /// The phone composer's "+" : a sheet of ways to add to the message. + /// Only the photo library for now; more rows go here, not in the row. + pub(in super::super) fn render_attach_menu(&self, cx: &mut Context) -> AnyElement { + let muted = cx.theme().muted_foreground; + let composer = cx.entity(); + let trigger = Button::new("attach-menu") + .debug_selector(|| "attach-menu".into()) + .min_w(px(44.)) + .min_h(px(44.)) + .ghost() + .compact() + .aria_label(crate::tr!("attach.add").into_owned()) + .child(Icon::new(IconName::Plus).small().text_color(muted)); + + crate::material::overlay_popover("attach-popover") + .anchor(Anchor::BottomLeft) + .bottom_sheet(crate::tr!("attach.add")) + .trigger(trigger) + .content(move |_, _window, cx| { + let composer = composer.clone(); + let popover = cx.entity(); + let muted = cx.theme().muted_foreground; + v_flex() + .w_full() + .p_1() + .gap_0p5() + .child( + h_flex() + .id("attach-photo-library") + .w_full() + .min_h(px(44.)) + .px_2() + .py_1p5() + .gap_1p5() + .items_center() + .rounded(px(6.)) + .cursor_pointer() + .text_size(px(13.)) + .text_color(muted) + .hover(|style| style.bg(cx.theme().muted)) + .child( + Icon::empty() + .path("icons/image.svg") + .small() + .text_color(muted), + ) + .child(crate::tr!("attach.photo_library")) + .on_click(move |_, window, cx| { + popover.update(cx, |state, cx| state.dismiss(window, cx)); + composer.update(cx, |composer, cx| { + composer.pick_images_from_library(window, cx) + }); + }), + ) + .into_any_element() + }) + .into_any_element() + } + pub(in super::super) fn render_overflow_menu(&self, cx: &mut Context) -> AnyElement { let composer = self.workspace_store.read(cx).composer_state(); let usage = composer.token_usage; diff --git a/crates/ui/src/composer/mod.rs b/crates/ui/src/composer/mod.rs index 441bacb6..b3e7b61f 100644 --- a/crates/ui/src/composer/mod.rs +++ b/crates/ui/src/composer/mod.rs @@ -1052,8 +1052,18 @@ impl Render for Composer { #[cfg(not(all(feature = "voice", target_os = "macos")))] let mic: Option = None; + // The platform picker is the phone's only way in; desktops paste and + // drop, and the readonly and offline states have nothing to add to. + let attach_menu = (self.compact && !readonly && self.interactive(cx)) + .then(|| { + cx.try_global::() + .is_some_and(|client| client.host().supports_image_picker()) + .then(|| self.render_attach_menu(cx)) + }) + .flatten(); let control_row = if self.compact || (readonly && compact) { control_row_base + .children(attach_menu) .child(div().flex_1().min_w_0().child(self.render_model_picker(cx))) .child(self.render_traits_picker(cx)) .child(self.render_primary_action(turn_running, cx)) diff --git a/crates/ui/src/settings_page.rs b/crates/ui/src/settings_page.rs index a63530d3..87948460 100644 --- a/crates/ui/src/settings_page.rs +++ b/crates/ui/src/settings_page.rs @@ -235,6 +235,11 @@ impl Section { /// change event can be told apart from a real edit; `dirty` then freezes the /// field against later snapshots so a host update cannot overwrite what the /// user is in the middle of typing. +/// The remote attachment ceiling as the settings field shows it, in MiB. +fn remote_attachment_limit_value(store: &WorkspaceStore) -> String { + (store.client_remote_attachment_limit_bytes() / (1024 * 1024)).to_string() +} + struct SettingsInput { state: Entity, pushed: String, @@ -319,6 +324,7 @@ pub struct SettingsPage { /// Editable "Home URL" for the Browser page; committed on change. home_url_input: SettingsInput, auto_archive_idle_input: SettingsInput, + remote_attachment_limit_input: SettingsInput, auto_archive_keep_input: SettingsInput, /// Zero-based page shown by the Archived Threads list. archived_page: usize, @@ -493,6 +499,9 @@ impl SettingsPage { }); let auto_archive_idle_input = cx.new(|cx| InputState::new(window, cx)); let auto_archive_keep_input = cx.new(|cx| InputState::new(window, cx)); + let remote_attachment_limit = remote_attachment_limit_value(store.read(cx)); + let remote_attachment_limit_input = + cx.new(|cx| InputState::new(window, cx).default_value(remote_attachment_limit.clone())); #[cfg(all(feature = "local-permissions", target_os = "macos"))] let local_permissions = capabilities.can_manage_local_permissions().then(|| { let store = store.clone(); @@ -520,6 +529,11 @@ impl SettingsPage { usage_refresh_sent: false, home_url_input: SettingsInput::new(home_url_input.clone()), auto_archive_idle_input: SettingsInput::new(auto_archive_idle_input.clone()), + remote_attachment_limit_input: SettingsInput { + state: remote_attachment_limit_input.clone(), + pushed: remote_attachment_limit, + dirty: false, + }, auto_archive_keep_input: SettingsInput::new(auto_archive_keep_input.clone()), archived_page: 0, hydrated: false, @@ -549,6 +563,17 @@ impl SettingsPage { } } })); + page._subscriptions.push(cx.subscribe( + &remote_attachment_limit_input, + |this, input, event, cx| { + if matches!(event, InputEvent::Change) { + let value = input.read(cx).value().to_string(); + if this.remote_attachment_limit_input.is_user_edit(&value) { + this.commit_remote_attachment_limit(cx); + } + } + }, + )); page._subscriptions.push(cx.subscribe( &auto_archive_idle_input, |this, input, event, cx| { @@ -632,6 +657,24 @@ impl SettingsPage { self.dispatch_settings(move |store| store.set_client_device_name(name), cx); } + fn commit_remote_attachment_limit(&self, cx: &mut Context) { + let Some(limit) = self + .remote_attachment_limit_input + .state + .read(cx) + .value() + .trim() + .parse::() + .ok() + else { + return; + }; + self.dispatch_settings( + move |store| store.set_client_remote_attachment_limit_mib(Some(limit)), + cx, + ); + } + fn commit_auto_archive_idle_days(&self, cx: &mut Context) { let Some(days) = self .auto_archive_idle_input @@ -1184,6 +1227,8 @@ impl SettingsPage { page.home_url_input.push(home_url, window, cx); let device_name = page.store.read(cx).client_device_name(); page.device_name_input.push(device_name, window, cx); + let limit = remote_attachment_limit_value(page.store.read(cx)); + page.remote_attachment_limit_input.push(limit, window, cx); page.auto_archive_idle_input.push( DEFAULT_AUTO_ARCHIVE_MAX_IDLE_DAYS.to_string(), window, @@ -1259,6 +1304,7 @@ impl SettingsPage { let language_overridden = store.client_language_override().is_some(); let theme_overridden = store.client_theme_override().is_some(); let device_name_overridden = store.client_device_name_override().is_some(); + let attachment_limit_overridden = store.client_remote_attachment_limit_override().is_some(); let provider_marks_reset = self.reset_action( "reset-sidebar-provider-marks", settings.sidebar_provider_marks, @@ -1280,6 +1326,7 @@ impl SettingsPage { WorkspaceStore::set_sidebar_provider_marks, ), self.device_name_row(device_name_overridden, cx), + self.remote_attachment_limit_row(attachment_limit_overridden, cx), ]; let delete_confirm_reset = self.reset_action( "reset-delete-confirm", @@ -1574,6 +1621,36 @@ impl SettingsPage { .into_any_element() } + fn remote_attachment_limit_row(&self, overridden: bool, cx: &mut Context) -> AnyElement { + let reset = self.reset_action( + "reset-remote-attachment-limit", + overridden, + cx, + |this, window, cx| { + this.dispatch_settings( + |store| store.set_client_remote_attachment_limit_mib(None), + cx, + ); + let limit = remote_attachment_limit_value(this.store.read(cx)); + this.remote_attachment_limit_input.push(limit, window, cx); + }, + ); + self.row_frame(cx) + .debug_selector(|| "settings-remote-attachment-limit-row".into()) + .child(self.row_labels( + crate::tr!("settings.remote_attachment_limit.title"), + crate::tr!("settings.remote_attachment_limit.description"), + reset, + cx, + )) + .child( + Input::new(&self.remote_attachment_limit_input.state) + .w(px(72.)) + .rounded(crate::material::radius_input()), + ) + .into_any_element() + } + fn render_tcode_update_popover(&self, cx: &mut Context) -> AnyElement { let status = self.store.read(cx).tcode_update_status(); let version = status.latest.unwrap_or_default(); diff --git a/crates/ui/src/store/mod.rs b/crates/ui/src/store/mod.rs index 31bb9e63..13ee3fcf 100644 --- a/crates/ui/src/store/mod.rs +++ b/crates/ui/src/store/mod.rs @@ -1683,6 +1683,44 @@ impl WorkspaceStore { self.save_client_preferences(); } + /// The device's own per-image ceiling for attachments sent across the + /// internet, in bytes. + pub fn client_remote_attachment_limit_bytes(&self) -> u64 { + self.client_preferences + .remote_attachment_limit_mib + .map_or(tcode_core::attachments::DEFAULT_REMOTE_BYTES, |mib| { + u64::from(mib) * 1024 * 1024 + }) + } + + pub fn client_remote_attachment_limit_override(&self) -> Option { + self.client_preferences.remote_attachment_limit_mib + } + + pub fn set_client_remote_attachment_limit_mib(&mut self, limit: Option) { + self.client_preferences.remote_attachment_limit_mib = limit.filter(|limit| *limit > 0); + self.save_client_preferences(); + } + + /// How an attachment from this client would reach the machine right now. + pub fn attachment_link(&self) -> crate::attachments::TransferLink { + use crate::attachments::TransferLink; + if !self.is_remote() { + return TransferLink::Local; + } + match self.connection_state() { + tcode_client::ConnectionState::Connected { path } + | tcode_client::ConnectionState::Syncing { path } => { + path.map_or(TransferLink::Unknown, |path| match path.kind() { + tcode_protocol::PathKind::Lan => TransferLink::Lan, + tcode_protocol::PathKind::Tunnel => TransferLink::Tunnel, + tcode_protocol::PathKind::Relay { .. } => TransferLink::Relay, + }) + } + _ => TransferLink::Unknown, + } + } + pub fn reset_client_preferences(&mut self) { self.client_preferences = ClientPreferences::default(); self.save_client_preferences(); diff --git a/docs/remote.md b/docs/remote.md index 5c59c337..78b73cdf 100644 --- a/docs/remote.md +++ b/docs/remote.md @@ -298,6 +298,16 @@ data directory for `--pair` and `--connect`. 4. Open a thread from the list, or use **+** to start one. Read replies, send or queue a message, steer a running turn, stop it, and answer approvals — the same views the desktop shows, laid out for the width. + The **+** at the left of the message field opens **Photo library**, the + system picker (PHPicker on iOS, the photo picker on Android 13 and later, + the document picker below that). Images are fitted on the device before + they leave it: no longer than 2048 pixels on either side, HEIC converted to + JPEG, then stored in the thread's attachments directory on the machine. + Over a LAN there is no further limit. Across the internet the device + applies **Attachment limit over the internet** from its own settings + (2 MB unless changed): a larger image asks before going over a direct + path, and is refused over a relay, because a relay is shared with everyone + on the service and the connection's other messages would wait behind it. 5. **Settings** is the full settings page, not a reduced copy. **Back** returns list → machines, which disconnects; leaving a thread keeps the connection. You connect to one machine at a time. @@ -352,7 +362,8 @@ the page's scheme, so `https://` pages use `wss://`. ### Device preferences -Appearance, language and device name belong to this device, not the machine: +Appearance, language, device name and the internet attachment limit belong to +this device, not the machine: an explicit choice on this device overrides the connected machine's replicated setting, and restoring that row reveals the machine's setting again. Native apps keep these preferences in `mobile.json` next to `hosts.json` in their own data diff --git a/locales/en.yml b/locales/en.yml index c206e6a7..69902ff1 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -273,6 +273,9 @@ settings: title: "Device name" description: "The name this device shows to machines it connects to." placeholder: "My device" + remote_attachment_limit: + title: "Attachment limit over the internet (MB)" + description: "Images larger than this ask before going over a direct internet path and are never sent through a relay. Local network connections are not limited." word_wrap: title: "Word wrap in diffs" description: "Wrap long lines in the diff panel by default." @@ -900,6 +903,13 @@ attach: unsupported_type: "Unsupported file type for '%{name}'. Please attach image files only." too_large: "'%{name}' exceeds the 10MB attachment limit." too_many: "You can attach up to 8 images per message." + add: "Add" + photo_library: "Photo library" + pick_failed: "Could not open the photo library: %{error}" + relay_rejected: "'%{name}' is %{size}. This device reaches the machine through a relay, which does not carry attachments over %{limit}; reduce the image or connect on the same network." + tunnel_title: "Large attachment" + tunnel_body: "'%{name}' is %{size} and this device reaches the machine across the internet, so sending it may take a while." + tunnel_confirm: "Send anyway" preview: external_unavailable: "Open the remote page in Preview first. Its forwarded browser link works only while this preview stays open." remote_forwarding: "Remote loopback URLs are forwarded. Other addresses open from this Mac." diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index ce2f5201..20975f04 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -273,6 +273,9 @@ settings: title: "设备名称" description: "这台设备连接主机时显示的名称。" placeholder: "我的设备" + remote_attachment_limit: + title: "互联网传输附件上限 (MB)" + description: "超过此大小的图片在互联网直连时会先询问,经中继时不会发送。局域网连接不受限制。" word_wrap: title: "diff 自动换行" description: "默认在 diff 面板中自动换行过长内容。" @@ -894,6 +897,13 @@ attach: unsupported_type: "'%{name}' 的文件类型不受支持。请仅附加图片文件。" too_large: "'%{name}' 超过了 10MB 的附件大小限制。" too_many: "每条消息最多只能附加 8 张图片。" + add: "添加" + photo_library: "相册" + pick_failed: "无法打开相册:%{error}" + relay_rejected: "'%{name}' 大小为 %{size}。当前设备经中继连接主机,超过 %{limit} 的附件不会经中继发送;请缩小图片或在同一网络下连接。" + tunnel_title: "附件较大" + tunnel_body: "'%{name}' 大小为 %{size},当前设备通过互联网直连主机,发送可能需要一些时间。" + tunnel_confirm: "仍然发送" preview: external_unavailable: "请先在预览中打开远程页面。转发后的浏览器链接仅在此预览保持打开时有效。" remote_forwarding: "远程回环地址通过转发访问,其他地址从此 Mac 直接访问。"