Subject: bug#76120: [PATCH] Expose the native sharing dialog (macOS)

From: Alvaro Ramirez <[email-masked]>
Date: Mon, 17 Feb 2025 19:49:59 +0000

Hi Eli and Stefan,

Attaching the latest iteration of the patch (renamed 
0003-Add-context-menu-share-to-mouse.el.patch) incorporating your 
feedback.

New in patch:

- Sharing/opening file externally is now generic and available via 
  context menu (needs context-menu-mode enabled) on multiple 
  platforms.
- Added file opening/sharing support for GNU/Linux via (xdg-open) 
  and any other platform which support it.
  - Region/text opening/sharing is now supported by xdg-open too.
- Sharing/opening routing behavior is now configurable and can 
  override the context menu implementation.
- Removes lambda usage from Objective-C/native code.
- Guards macOS addition via NS_IMPL_COCOA (avoid breaking 
  GNUstep).

While routing logic is now configurable, it'be great to extend the 
default behavior for other platforms. Android sounds like a great 
candidate as per Rudolf's suggested API 
https://debbugs.gnu.org/cgi/bugreport.cgi?bug=76120#47. Any 
Android folks keen on extending the feature?

Also attaching screenshots as per current patch for both GNU/Linux 
and macOS.

Álvaro

>From bc9cefb29a70cf4d648efa291d63e4c7c17258ca Mon Sep 17 00:00:00 2001
From: xenodium <[email-masked]>
Date: Thu, 13 Feb 2025 17:30:01 +0000
Subject: [PATCH] Add context-menu-share to mouse.el

* lisp/context-share.el: New package implements sharing capability.
Leverages platform's available file sharing/opening capabilities.

* lisp/mouse.el (context-menu-share): Add "Share..." context menu.

* lisp/term/ns-win.el (ns-share-items): Expose native macOS share API.

* src/nsfns.m (ns-share-items): Implement native macOS sharing.

* etc/NEWS: Announce the new feature.
---
 etc/NEWS              |   7 ++
 lisp/context-share.el | 214 ++++++++++++++++++++++++++++++++++++++++++
 lisp/mouse.el         |  17 +++-
 lisp/term/ns-win.el   |   1 +
 src/nsfns.m           |  62 ++++++++++++
 5 files changed, 300 insertions(+), 1 deletion(-)
 create mode 100644 lisp/context-share.el

diff --git a/etc/NEWS b/etc/NEWS
index 31109f0857c..5c08c72198c 100644
--- a/etc/NEWS
+++ b/etc/NEWS
@@ -272,6 +272,13 @@ customize help text for tabs displayed on the tab-bar.  Help text is
 normally shown in the echo area or via tooltips.  See the variable's
 docstring for arguments passed to a help-text function.
 
+** Mouse
+
+---
+*** context-menu-mode now includes a "Share..." menu item.
+The share menu item enables sharing/opening current file(s) with external
+(non-Emacs) apps or services. See context-share.el for customisations.
+
 ** Project
 
 ---
diff --git a/lisp/context-share.el b/lisp/context-share.el
new file mode 100644
index 00000000000..1c82f454b4d
--- /dev/null
+++ b/lisp/context-share.el
@@ -0,0 +1,214 @@
+;;; context-share.el --- share/open files externally  -*- lexical-binding: t -*-
+
+;; Copyright (C) 1993-2025 Free Software Foundation, Inc.
+
+;; Maintainer: [email-masked]
+;; Keywords: share, open
+;; Package: emacs
+
+;; This file is part of GNU Emacs.
+
+;; GNU Emacs is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; GNU Emacs is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This package provides commands to facilitate sharing files with
+;; external apps or services.
+;;
+;; Enable `context-menu-mode' to get a "Share..." context menu item.
+;;
+;; `context-share' uses `context-share-context-items-function' to
+;; offer file(s) to share/open and `context-share-handler-function' to
+;; route external handling.
+
+;;; Code:
+
+(require 'seq)
+
+(declare-function dired-between-files "dired")
+(declare-function dired-get-filename "dired")
+(declare-function dired-get-marked-files "dired")
+(declare-function dired-move-to-filename "dired")
+
+(defgroup context-share nil
+  "Share files and text with external applications."
+  :group 'external
+  :version "31.1")
+
+(defcustom context-share-support-checker-function #'context-share--default-support-checker-p
+  "A function returning non-nil when sharing is supported by current platform."
+  :type '(function :tag "Function")
+  :group 'context-share
+  :version "31.1")
+
+(defcustom context-share-handler-function #'context-share--default-handler
+  "A function handling `context-share' external routing.
+
+The function receives a list with shared items.
+
+Each item can be a either a filename or plain text."
+  :type '(function :tag "Function")
+  :group 'context-share
+  :version "31.1")
+
+(defcustom context-share-context-items-function #'context-share--default-context-items
+  "A function to collect items offered to share by `context-share'.
+
+The function returns a list of items, where each item can be a either
+a filename or plain text."
+  :type '(function :tag "Function")
+  :group 'context-share
+  :version "31.1")
+
+;;;###autoload
+(defun context-share-supported-p ()
+  "Return non-nil for platforms where `context-share' is supported."
+  (unless context-share-support-checker-function
+    (error "`context-share-support-checker-function' must be set"))
+  (funcall context-share-support-checker-function))
+
+;;;###autoload
+(defun context-share (&optional items)
+  "Share file(s) or region text with external apps by context.
+
+Sharing implementation handled by `context-share-handler-function'.
+
+ITEMS list is automatically populated based on context.
+See `context-share-context-items-function' for details.
+
+ITEMS can be overridden to share a specific list of items."
+  (interactive)
+  (unless context-share-handler-function
+    (error "`context-share-handler-function' must be set"))
+  (unless context-share-context-items-function
+    (error "`context-share-context-items-function' must be set"))
+  (dolist (item items)
+    (unless (stringp item)
+      (error "Item must be a string: %s" item)))
+  (funcall context-share-handler-function
+           (or items
+               (funcall context-share-context-items-function)
+               (user-error "Nothing to share"))))
+
+(defun context-share--format-items (items)
+  "Format ITEMS into a user-presentable message string."
+  (truncate-string-to-width
+   (string-join
+    (seq-map (lambda (item)
+               (if (and (stringp item)
+                        (file-exists-p item))
+                   (format "\"%s\"" (file-name-nondirectory item))
+                 (format "\"%s\"" (truncate-string-to-width item 35 nil nil "…"))))
+             items) " ") 70 nil nil "…"))
+
+(defun context-share--dired-filenames-in-region ()
+  "If in `dired' buffer, return region files.  nil otherwise."
+  (when (and (derived-mode-p 'dired-mode)
+             (use-region-p))
+    (let* ((start (region-beginning))
+           (end (region-end))
+           (marked-files (dired-get-marked-files nil nil nil t))
+           (active-marks-p (if (= (length marked-files) 1)
+                               nil ;; File found at point (not marked)
+                             (not (seq-empty-p marked-files))))
+           (filenames))
+      (when active-marks-p
+        (user-error "Either mark `dired' files or select a region, but not both"))
+      (save-excursion
+        (save-restriction
+          (goto-char start)
+          (while (< (point) end)
+            ;; Skip non-file lines.
+            (while (and (< (point) end) (dired-between-files))
+              (forward-line 1))
+            (when (and (dired-get-filename nil t)
+                       ;; Filename must be in region.
+                       (< (save-excursion
+                            (forward-line 0)
+                            (dired-move-to-filename))
+                          end))
+              (setq filenames (append filenames (list (dired-get-filename nil t)))))
+            (forward-line 1))))
+      filenames)))
+
+(defun context-share--default-support-checker-p ()
+  "Return non-nil for platforms supporting sharing capability."
+  (or (and (featurep 'ns) (fboundp 'ns-share-items))
+      (executable-find "xdg-open")))
+
+(defun context-share--default-handler (items)
+  "Share ITEMS with external (non-Emacs) apps or services.
+
+ITEMS can be filenames of files to share or any text.
+
+Text is written to a temporary file before sharing."
+  (unless items
+    (error "Nothing to share"))
+  (cond ((and (featurep 'ns) (fboundp 'ns-share-items))
+         (ns-share-items
+          (seq-map #'context-share--convert-item-to-filename items)))
+        ((executable-find "xdg-open")
+         (when (y-or-n-p (format "Open externally: %s ?"
+                                 (context-share--format-items items)))
+           (dolist (item items)
+             (with-temp-buffer
+               (unless (zerop (call-process
+                               "xdg-open" nil (current-buffer) t
+                               (context-share--convert-item-to-filename
+                                item)))
+                 (error "%s" (string-trim (buffer-string))))))))
+        (t
+         (error "Don't know how to share %s (adjust `context-share-handler-function')"
+                (context-share--format-items items)))))
+
+(defun context-share--convert-item-to-filename (item)
+  "Convert ITEM to a filename.
+
+Unless ITEM is a verifiable filename, save its content to a file and
+return its new timestamped filename."
+  (if (file-exists-p item)
+      item
+    (let ((filename (concat temporary-file-directory
+                            (format-time-string "%F_%H.%M.%S") ".txt")))
+      (with-temp-file filename
+        (insert item))
+      filename)))
+
+(defun context-share--default-context-items ()
+  "Build a list of shareable items based on default context.
+
+From a `dired' buffer, shared items are based on either of these being active:
+
+  - Marked files
+  - Files in region.
+  - File at point.
+
+From any other buffer, either of these two, in order of preference:
+
+  - Active region text.
+  - Buffer file."
+  (cond ((derived-mode-p 'dired-mode)
+         (or
+          (context-share--dired-filenames-in-region)
+          (dired-get-marked-files)))
+        ((use-region-p)
+         (list (buffer-substring-no-properties
+                (region-beginning)
+                (region-end))))
+        ((buffer-file-name)
+         (list (buffer-file-name)))))
+
+(provide 'context-share)
+
+;;; context-share.el ends here
diff --git a/lisp/mouse.el b/lisp/mouse.el
index 1f0ca6a51b6..069fe1f16cc 100644
--- a/lisp/mouse.el
+++ b/lisp/mouse.el
@@ -30,6 +30,7 @@
 ;;; Code:
 
 (eval-when-compile (require 'rect))
+(eval-when-compile (require 'context-share))
 
 ;; Indent track-mouse like progn.
 (put 'track-mouse 'lisp-indent-function 0)
@@ -393,7 +394,8 @@ context-menu-functions
                                     context-menu-region
                                     context-menu-middle-separator
                                     context-menu-local
-                                    context-menu-minor)
+                                    context-menu-minor
+                                    context-menu-share)
   "List of functions that produce the contents of the context menu.
 Each function receives the menu and the mouse click event as its arguments
 and should return the same menu with changes such as added new menu items."
@@ -536,6 +538,19 @@ context-menu-minor
                   (cdr mode))))
   menu)
 
+(defun context-menu-share (menu _click)
+  "Add sharing context MENU entry on supported platforms."
+  (run-hooks 'activate-menubar-hook 'menu-bar-update-hook)
+  (when (context-share-supported-p)
+    (define-key-after menu [separator-share] menu-bar-separator)
+    (define-key-after menu [share]
+      '(menu-item "Share..." (lambda ()
+                               (interactive)
+                               (context-share))
+                  :help
+                  "Share item (region, buffer file, or dired files) with external apps")))
+  menu)
+
 (defun context-menu-buffers (menu _click)
   "Populate MENU with the buffer submenus to buffer switching."
   (run-hooks 'activate-menubar-hook 'menu-bar-update-hook)
diff --git a/lisp/term/ns-win.el b/lisp/term/ns-win.el
index 46639f2422b..fa3c68af629 100644
--- a/lisp/term/ns-win.el
+++ b/lisp/term/ns-win.el
@@ -214,6 +214,7 @@ 'mac-option-modifier
 (defvaralias 'mac-right-option-modifier 'ns-right-option-modifier)
 (defvaralias 'mac-function-modifier 'ns-function-modifier)
 (declare-function ns-do-applescript "nsfns.m" (script))
+(declare-function ns-share-items "nsfns.m" (items))
 (defalias 'do-applescript 'ns-do-applescript)
 
 ;;;; Services
diff --git a/src/nsfns.m b/src/nsfns.m
index a2c50468cd1..175a0b2fcb8 100644
--- a/src/nsfns.m
+++ b/src/nsfns.m
@@ -3771,6 +3771,65 @@ The position is returned as a cons cell (X . Y) of the
   return Qnil;
 }
 
+#ifdef NS_IMPL_COCOA
+
+static NSPoint
+ns_get_point_relative_coordinates (NSView* view)
+{
+  Lisp_Object coordinate = Fnth (make_fixnum (2), Fposn_at_point (Fpoint (), Qnil));
+
+  Lisp_Object x = Fcar (coordinate);
+  if (NILP (x))
+  {
+    signal_error ("No x coordinate found", coordinate);
+  }
+
+  Lisp_Object y = Fcdr (coordinate);
+  if (NILP (y))
+  {
+    signal_error ("No y coordinate found", coordinate);
+  }
+
+  return [view convertPoint:NSMakePoint (XFIXNUM (x), XFIXNUM (y)) toView:nil];
+}
+
+DEFUN ("ns-share-items",
+       Fns_share_items,
+       Sns_share_items, 1, 1, 0,
+       doc: /* Share a list of items via macOS sharing service. */)
+       (Lisp_Object items)
+{
+  CHECK_LIST (items);
+
+  NSMutableArray *shared = [NSMutableArray array];
+  for (Lisp_Object tail = items; CONSP (tail); tail = XCDR (tail))
+  {
+    Lisp_Object elt = XCAR (tail);
+    if (!(STRINGP (elt)))
+    {
+      signal_error ("Element not a string", elt);
+    }
+    BOOL isDir;
+    if ([[NSFileManager defaultManager] fileExistsAtPath:@(SSDATA(elt)) isDirectory:&isDir]) {
+      [shared addObject:[NSURL fileURLWithPath:@(SSDATA(elt))]];
+      continue;
+    }
+    [shared addObject:@(SSDATA(elt))];
+  }
+
+  struct frame *frame = SELECTED_FRAME ();
+  EmacsView *view = FRAME_NS_VIEW (frame);
+  NSPoint point = ns_get_point_relative_coordinates (view);
+  NSRect displayArea = NSMakeRect (point.x + 15, view.bounds.size.height - point.y + 25, 1, 1);
+
+  NSSharingServicePicker *picker = [[NSSharingServicePicker alloc] initWithItems:shared];
+  [picker showRelativeToRect:displayArea ofView:view preferredEdge:NSRectEdgeMaxY];
+
+  return Qnil;
+}
+
+#endif /* NS_IMPL_COCOA */
+
 /* ==========================================================================
 
     Class implementations
@@ -4003,6 +4062,9 @@ - (Lisp_Object)lispString
   defsubr (&Sns_set_mouse_absolute_pixel_position);
   defsubr (&Sns_mouse_absolute_pixel_position);
   defsubr (&Sns_show_character_palette);
+#ifdef NS_IMPL_COCOA
+  defsubr (&Sns_share_items);
+#endif
   defsubr (&Sx_display_mm_width);
   defsubr (&Sx_display_mm_height);
   defsubr (&Sx_display_screens);
-- 
2.39.5 (Apple Git-154)