diff --git a/ios/README.md b/ios/README.md index b760372..bde0c4e 100644 --- a/ios/README.md +++ b/ios/README.md @@ -46,7 +46,9 @@ is removed. Settings live in Application Support; the working directory is Documents. Open starts the native document picker in Documents. Local documents open directly; external selections are read with security-scoped access and file coordination, then copied into Documents under a unique name. Native Save asks -for a filename, applies the filter extension and confirms replacement. External directory picking, offscreen native +for a filename, applies the filter extension and confirms replacement. Files dragged in +from other apps are copied into Documents under a unique name and posted as +`kEventDragDrop` at the drop point, one event per file. External directory picking, offscreen native windows and joystick discovery are not implemented; rejected requests log to stderr. Orion routes both Open and Save to these native dialogs. diff --git a/ios/ios_local.h b/ios/ios_local.h index 6869171..45a34f7 100644 --- a/ios/ios_local.h +++ b/ios/ios_local.h @@ -20,6 +20,7 @@ extern EAGLContext *ios_context; extern CADisplayLink *ios_display_link; void ios_resize_surface(void); void ios_cancel_timers(void); +void ios_install_drop(UIView *view); void ios_post_gesture(ax_gesture_t gesture); void ios_post_touch(uint32_t event, uint32_t wparam, void *lparam, ax_pointer_t pointer); #endif diff --git a/ios/ios_system.m b/ios/ios_system.m index 57a511b..72161c5 100644 --- a/ios/ios_system.m +++ b/ios/ios_system.m @@ -32,6 +32,65 @@ void axJoystickShutdown(void) {} return url; } +// "name", then "name 2", "name 3", ... starting at the given attempt. +static NSURL *ios_unique_document(NSURL *documents, NSString *name, int attempt) { + NSURL *destination = nil; + for (int suffix = attempt; !destination || [NSFileManager.defaultManager fileExistsAtPath:destination.path]; suffix++) { + NSString *stem = name; + if (suffix > 1) { + stem = [NSString stringWithFormat:@"%@ %d", name.stringByDeletingPathExtension, suffix]; + if (name.pathExtension.length) stem = [stem stringByAppendingPathExtension:name.pathExtension]; + } + destination = [documents URLByAppendingPathComponent:stem]; + } + return destination; +} + +// Files dragged in from other apps are copied into Documents, then posted as +// kEventDragDrop at the drop point, one event per file. +@interface AXDropDelegate : NSObject +@end +@implementation AXDropDelegate +- (BOOL)dropInteraction:(UIDropInteraction *)interaction canHandleSession:(id)session { + return [session hasItemsConformingToTypeIdentifiers:@[UTTypeData.identifier]]; +} +- (UIDropProposal *)dropInteraction:(UIDropInteraction *)interaction sessionDidUpdate:(id)session { + return [[UIDropProposal alloc] initWithDropOperation:UIDropOperationCopy]; +} +- (void)dropInteraction:(UIDropInteraction *)interaction performDrop:(id)session { + CGPoint at = [session locationInView:interaction.view]; + NSURL *documents = ios_documents_url(); + if (!documents) return; + for (UIDragItem *item in session.items) { + NSItemProvider *provider = item.itemProvider; + NSString *type = nil; + for (NSString *candidate in provider.registeredTypeIdentifiers) + if ([[UTType typeWithIdentifier:candidate] conformsToType:UTTypeData]) { type = candidate; break; } + if (!type) { IOS_TRACE("drop item has no file representation types=%s", provider.registeredTypeIdentifiers.description.UTF8String); continue; } + [provider loadFileRepresentationForTypeIdentifier:type completionHandler:^(NSURL *url, NSError *error) { + if (!url) { IOS_TRACE("drop load failed type=%s: %s", type.UTF8String, error.localizedDescription.UTF8String); return; } + // The provider's file is deleted when this handler returns; concurrent drops may race for a name. + NSError *copy_error = nil; + NSURL *destination = nil; + for (int attempt = 1; attempt < 100; attempt++) { + destination = ios_unique_document(documents, url.lastPathComponent, attempt); + copy_error = nil; + if ([NSFileManager.defaultManager copyItemAtURL:url toURL:destination error:©_error]) break; + if (copy_error.code != NSFileWriteFileExistsError) break; + } + if (copy_error) { IOS_TRACE("drop import failed name=%s: %s", url.lastPathComponent.UTF8String, copy_error.localizedDescription.UTF8String); return; } + axNotifyFileDropEvent(destination.fileSystemRepresentation, (float)at.x, (float)at.y); + }]; + } +} +@end + +void ios_install_drop(UIView *view) { + static AXDropDelegate *delegate; // UIDropInteraction holds its delegate weakly + if (!delegate) delegate = [AXDropDelegate new]; + [view addInteraction:[[UIDropInteraction alloc] initWithDelegate:delegate]]; +} + static void ios_wait_for_dismissal(void) { while (ios_window.rootViewController.presentedViewController) axWaitMessage(10); } @@ -80,16 +139,8 @@ bool_t axGetOpenFileName(AXopenfilename const *ofn) { NSString *root = [documents.URLByResolvingSymlinksInPath.path stringByAppendingString:@"/"]; BOOL local = [source.URLByResolvingSymlinksInPath.path hasPrefix:root]; BOOL ok = YES; - if (!local) { - // Import external providers into permanent, app-owned storage without replacing another drawing. - NSString *name = source.lastPathComponent; - destination = [documents URLByAppendingPathComponent:name]; - for (int suffix = 2; [NSFileManager.defaultManager fileExistsAtPath:destination.path]; suffix++) { - NSString *stem = [NSString stringWithFormat:@"%@ %d", name.stringByDeletingPathExtension, suffix]; - if (name.pathExtension.length) stem = [stem stringByAppendingPathExtension:name.pathExtension]; - destination = [documents URLByAppendingPathComponent:stem]; - } - } + // Import external providers into permanent, app-owned storage without replacing another drawing. + if (!local) destination = ios_unique_document(documents, source.lastPathComponent, 1); if (strlen(destination.fileSystemRepresentation) >= ofn->nMaxFile) { ios_file_error(@"The selected filename is too long."); ok = NO; } else if (!local) { diff --git a/ios/ios_window.m b/ios/ios_window.m index 5b1f0fb..98d4ea1 100644 --- a/ios/ios_window.m +++ b/ios/ios_window.m @@ -207,6 +207,7 @@ - (void)viewDidLoad { [ios_view.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], [ios_view.topAnchor constraintEqualToAnchor:self.view.topAnchor], [ios_view.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor]]]; + ios_install_drop(ios_view); [ios_view addGestureRecognizer:[[UIHoverGestureRecognizer alloc] initWithTarget:ios_view action:@selector(hover:)]]; UIPanGestureRecognizer *scroll = [[UIPanGestureRecognizer alloc] initWithTarget:ios_view action:@selector(scroll:)]; scroll.minimumNumberOfTouches = 2; diff --git a/platform.h b/platform.h index 3b16ddf..0db0418 100644 --- a/platform.h +++ b/platform.h @@ -1219,6 +1219,7 @@ axMutexDestroy(axmutex_t mutex); * | `drag ` | Press at (x1,y1), drag to (x2,y2), release | * | `move ` | Mouse moved to (x, y) | * | `scroll ` | Scroll at (x, y) by the given wheel delta | + * | `drop ` | Drop the file at path on (x, y), as a file dragged in | * | `key [mods]` | Key down + key up (virtual key code integer) | * | `keydown [mods]` | Key down only | * | `keyup [mods]` | Key up only | diff --git a/rc_server_impl.c b/rc_server_impl.c index 62fe420..11b9eb6 100644 --- a/rc_server_impl.c +++ b/rc_server_impl.c @@ -121,6 +121,15 @@ rc_dispatch(int conn, const char *line) (void *)(intptr_t)MAKEDWORD(dx, dy)); rc_reply(conn, "ok\n"); + } else if (strncmp(line, "drop ", 5) == 0) { + int consumed = 0; + if (sscanf(line + 5, "%d %d %n", &x, &y, &consumed) == 2 && line[5 + consumed]) { + axNotifyFileDropEvent(line + 5 + consumed, (float)x, (float)y); + rc_reply(conn, "ok\n"); + } else { + rc_reply(conn, "err usage: drop \n"); + } + /* --- Keyboard input --- */ } else if (sscanf(line, "keydown %d %d", &code, &mods) == 2) {