feat: add clipboard file handling for Mattermost and implement batch debounce functionality

This commit is contained in:
2026-05-15 07:54:01 -06:00
parent 7fc4320f46
commit 456a4c3381
5 changed files with 322 additions and 23 deletions

View File

@@ -9,14 +9,14 @@ clipboard behavior.
`opencode`
- Saves to `ai/inbox/photos/`
- Copies a terminal-safe path to the clipboard
- Copies terminal-safe paths to the clipboard
- Best for pasting into OpenCode running in a terminal
`mattermost`
- Saves to `~/Pictures/iPhone Inbox`
- Copies the image data to the clipboard
- Best for pasting directly into Mattermost
- Copies native macOS file URLs to the clipboard
- Best effort for pasting one or more files directly into Mattermost
`general`
@@ -26,6 +26,28 @@ clipboard behavior.
All profiles show a macOS notification by default.
## Batch debounce
Uploads are grouped by profile. Every new photo extends the active profile batch
by 10 seconds and immediately refreshes the clipboard with the full batch.
Example for `opencode`:
```text
photo 1 arrives -> clipboard has photo 1 path
photo 2 arrives within 10s -> clipboard has photo 1 + photo 2 paths
photo 3 arrives within 10s -> clipboard has photo 1 + photo 2 + photo 3 paths
10s pass with no new photo -> notification says the batch is ready
```
This keeps the Shortcut simple while still making the clipboard usable before
the final notification appears.
For `mattermost`, each upload rewrites the clipboard with the full batch using a
small Swift helper and `NSPasteboard.writeObjects`. This is closer to Finder's
file-copy behavior than the older AppleScript alias approach. If the native
helper fails, the receiver falls back to AppleScript and logs the fallback.
## Start the receiver
Recommended:
@@ -150,17 +172,25 @@ Clipboard override for all profiles:
```bash
IPHONE_PHOTO_CLIPBOARD=image
IPHONE_PHOTO_CLIPBOARD=files
IPHONE_PHOTO_CLIPBOARD=terminal-path
IPHONE_PHOTO_CLIPBOARD=path
IPHONE_PHOTO_CLIPBOARD=file
IPHONE_PHOTO_CLIPBOARD=none
```
Debounce override:
```bash
IPHONE_PHOTO_DEBOUNCE_SECONDS=5
```
Other useful options:
```bash
python3 scripts/iphone-photo-inbox/receiver.py --no-notify
python3 scripts/iphone-photo-inbox/receiver.py --reveal
IPHONE_PHOTO_DEBUG=1 python3 scripts/iphone-photo-inbox/receiver.py
```
## Troubleshooting
@@ -169,26 +199,43 @@ Startup should print each active profile:
```text
profile opencode: dir=... clipboard=terminal-path notify=True reveal=False
profile mattermost: dir=... clipboard=image notify=True reveal=False
profile mattermost: dir=... clipboard=files notify=True reveal=False
```
After upload, expect:
After each upload, expect:
```text
notification sent
clipboard mode applied: terminal-path
saved ... profile=opencode
clipboard mode applied: terminal-path profile=opencode count=2
saved ... profile=opencode batch_count=2
```
For Mattermost, expect:
After the debounce window closes, expect:
```text
clipboard mode applied: image
batch notification sent profile=opencode count=2
batch finalized profile=opencode count=2 dir=...
```
The native file clipboard helper lives at:
```text
scripts/iphone-photo-inbox/copy_files_to_clipboard.swift
```
For faster multi-file clipboard updates during debounce, compile it once:
```bash
swiftc scripts/iphone-photo-inbox/copy_files_to_clipboard.swift \
-o scripts/iphone-photo-inbox/copy_files_to_clipboard
```
The compiled binary is ignored by git. If it is not present, the receiver can
run the Swift script directly, but that is slower on first use.
If files arrive but clipboard/notifications do not behave as expected, check:
- The Shortcut URL includes the intended `profile=`.
- The receiver log shows the expected profile.
- With `IPHONE_PHOTO_DEBUG=1`, the Mattermost profile should report `pasteboard files=2 items=2` after the second photo in a two-photo batch.
- macOS Focus/Do Not Disturb is not hiding notifications.
- Terminal/Codex has permission for AppleScript automation if macOS prompts.

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env swift
import AppKit
import Foundation
let paths = CommandLine.arguments.dropFirst()
guard !paths.isEmpty else {
fputs("usage: copy_files_to_clipboard.swift <path> [path...]\n", stderr)
exit(64)
}
let urls = paths.map { URL(fileURLWithPath: $0).standardizedFileURL }
let missing = urls.filter { !FileManager.default.fileExists(atPath: $0.path) }
guard missing.isEmpty else {
for url in missing {
fputs("missing file: \(url.path)\n", stderr)
}
exit(66)
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
let wroteObjects = pasteboard.writeObjects(urls as [NSURL])
let filenamesType = NSPasteboard.PasteboardType("NSFilenamesPboardType")
let pathsList = urls.map(\.path)
let wroteFilenames = pasteboard.setPropertyList(pathsList, forType: filenamesType)
let wrotePlainText = pasteboard.setString(pathsList.joined(separator: "\n"), forType: .string)
if wroteObjects || wroteFilenames || wrotePlainText {
let itemCount = pasteboard.pasteboardItems?.count ?? 0
fputs("pasteboard files=\(urls.count) items=\(itemCount) objects=\(wroteObjects) filenames=\(wroteFilenames) text=\(wrotePlainText)\n", stderr)
exit(0)
}
fputs("failed to write file URLs to pasteboard\n", stderr)
exit(1)

View File

@@ -9,6 +9,7 @@ import json
import os
import shlex
import subprocess
import threading
from dataclasses import dataclass
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -18,6 +19,9 @@ from urllib.parse import parse_qs, urlparse
WORKSPACE_DIR = Path(__file__).resolve().parents[2]
SCRIPT_DIR = Path(__file__).resolve().parent
COPY_FILES_HELPER = SCRIPT_DIR / "copy_files_to_clipboard.swift"
COPY_FILES_HELPER_BIN = SCRIPT_DIR / "copy_files_to_clipboard"
DEFAULT_OPENCODE_DIR = WORKSPACE_DIR / "ai" / "inbox" / "photos"
DEFAULT_MATTERMOST_DIR = Path.home() / "Pictures" / "iPhone Inbox"
@@ -26,6 +30,7 @@ CLIPBOARD_IMAGE = "image"
CLIPBOARD_PATH = "path"
CLIPBOARD_TERMINAL_PATH = "terminal-path"
CLIPBOARD_FILE = "file"
CLIPBOARD_FILES = "files"
@dataclass(frozen=True)
@@ -55,7 +60,7 @@ def profile_defaults() -> dict[str, Profile]:
general_dir = env_path("IPHONE_PHOTO_GENERAL_DIR", DEFAULT_MATTERMOST_DIR)
return {
"opencode": Profile("opencode", opencode_dir, CLIPBOARD_TERMINAL_PATH),
"mattermost": Profile("mattermost", mattermost_dir, CLIPBOARD_IMAGE),
"mattermost": Profile("mattermost", mattermost_dir, CLIPBOARD_FILES),
"general": Profile("general", general_dir, CLIPBOARD_NONE),
}
@@ -93,6 +98,11 @@ def run_macos_action(command: list[str]) -> bool:
print(f"macOS action failed: {error}", flush=True)
return False
if env_flag("IPHONE_PHOTO_DEBUG"):
output = "\n".join(part for part in [result.stdout.strip(), result.stderr.strip()] if part)
if output:
print(f"macOS action output: {output}", flush=True)
return True
@@ -111,6 +121,10 @@ def copy_image_to_clipboard(path: Path) -> bool:
def copy_file_to_clipboard(path: Path) -> bool:
return copy_files_to_clipboard([path])
def copy_file_to_clipboard_with_applescript(path: Path) -> bool:
script = f"""
set theFile to POSIX file {json.dumps(str(path))} as alias
tell application "Finder"
@@ -120,6 +134,47 @@ end tell
return run_macos_action(["osascript", "-e", script])
def copy_files_to_clipboard(paths: list[Path]) -> bool:
if not paths:
return True
helper = COPY_FILES_HELPER
if COPY_FILES_HELPER_BIN.exists():
source_mtime = COPY_FILES_HELPER.stat().st_mtime if COPY_FILES_HELPER.exists() else 0
helper_mtime = COPY_FILES_HELPER_BIN.stat().st_mtime
if helper_mtime >= source_mtime:
helper = COPY_FILES_HELPER_BIN
else:
print("compiled pasteboard helper is older than source; using Swift script", flush=True)
if helper.exists():
if run_macos_action([str(helper), *[str(path) for path in paths]]):
return True
print("native pasteboard helper failed; falling back to AppleScript", flush=True)
return copy_files_to_clipboard_with_applescript(paths)
def copy_files_to_clipboard_with_applescript(paths: list[Path]) -> bool:
if not paths:
return True
alias_lines = [
f"set end of theFiles to POSIX file {json.dumps(str(path))} as alias"
for path in paths
]
script = "\n".join(
[
"set theFiles to {}",
*alias_lines,
'tell application "Finder"',
" set the clipboard to theFiles",
"end tell",
]
)
return run_macos_action(["osascript", "-e", script])
def copy_text_to_clipboard(text: str) -> bool:
script = f"set the clipboard to {json.dumps(text)}"
return run_macos_action(["osascript", "-e", script])
@@ -129,21 +184,95 @@ def terminal_path_reference(path: Path) -> str:
return shlex.quote(str(path))
def apply_clipboard_mode(mode: str, path: Path) -> bool:
def paths_reference(paths: list[Path]) -> str:
return "\n".join(str(path) for path in paths)
def terminal_paths_reference(paths: list[Path]) -> str:
return "\n".join(terminal_path_reference(path) for path in paths)
def apply_clipboard_mode(mode: str, paths: list[Path]) -> bool:
if mode == CLIPBOARD_NONE:
return True
if not paths:
return True
latest = paths[-1]
if mode == CLIPBOARD_IMAGE:
return copy_image_to_clipboard(path)
return copy_image_to_clipboard(latest)
if mode == CLIPBOARD_PATH:
return copy_text_to_clipboard(str(path))
return copy_text_to_clipboard(paths_reference(paths))
if mode == CLIPBOARD_TERMINAL_PATH:
return copy_text_to_clipboard(terminal_path_reference(path))
return copy_text_to_clipboard(terminal_paths_reference(paths))
if mode == CLIPBOARD_FILE:
return copy_file_to_clipboard(path)
return copy_file_to_clipboard(latest)
if mode == CLIPBOARD_FILES:
return copy_files_to_clipboard(paths)
print(f"unsupported clipboard mode: {mode}", flush=True)
return False
@dataclass
class Batch:
profile: Profile
output_dir: Path
paths: list[Path]
started_at: dt.datetime
updated_at: dt.datetime
timer: threading.Timer | None = None
@property
def count(self) -> int:
return len(self.paths)
class BatchManager:
def __init__(self, debounce_seconds: float) -> None:
self.debounce_seconds = debounce_seconds
self.lock = threading.Lock()
self.batches: dict[str, Batch] = {}
def add(self, profile: Profile, output_dir: Path, path: Path) -> tuple[int, list[Path]]:
with self.lock:
now = dt.datetime.now()
batch = self.batches.get(profile.name)
if batch is None:
batch = Batch(profile, output_dir, [], now, now)
self.batches[profile.name] = batch
batch.profile = profile
batch.output_dir = output_dir
batch.paths.append(path)
batch.updated_at = now
if batch.timer is not None:
batch.timer.cancel()
batch.timer = threading.Timer(self.debounce_seconds, self.finish, args=(profile.name,))
batch.timer.daemon = True
batch.timer.start()
return batch.count, list(batch.paths)
def finish(self, profile_name: str) -> None:
with self.lock:
batch = self.batches.pop(profile_name, None)
if batch is None:
return
if batch.profile.notify:
plural = "photo" if batch.count == 1 else "photos"
message = f"{batch.profile.name}: {batch.count} {plural} ready; clipboard={batch.profile.clipboard}"
if notify("iPhone Photo Inbox", message):
print(f"batch notification sent profile={batch.profile.name} count={batch.count}", flush=True)
print(
f"batch finalized profile={batch.profile.name} count={batch.count} "
f"dir={batch.output_dir}",
flush=True,
)
class UploadHandler(BaseHTTPRequestHandler):
server_version = "iPhonePhotoInbox/2.0"
@@ -201,17 +330,20 @@ class UploadHandler(BaseHTTPRequestHandler):
temp_path = Path(tmp.name)
temp_path.replace(target)
if profile.notify:
if notify("iPhone Photo Inbox", f"{profile.name}: {target.name}"):
print("notification sent", flush=True)
if profile.reveal:
if reveal_in_finder(target):
print("revealed in Finder", flush=True)
if apply_clipboard_mode(profile.clipboard, target):
print(f"clipboard mode applied: {profile.clipboard}", flush=True)
batch_count, batch_paths = self.server.batch_manager.add(profile, output_dir, target)
if apply_clipboard_mode(profile.clipboard, batch_paths):
print(
f"clipboard mode applied: {profile.clipboard} "
f"profile={profile.name} count={batch_count}",
flush=True,
)
self.send_text(HTTPStatus.CREATED, f"{target}\n")
print(f"saved {target} profile={profile.name}", flush=True)
print(f"saved {target} profile={profile.name} batch_count={batch_count}", flush=True)
def log_message(self, format: str, *args: object) -> None:
print(f"{self.address_string()} - {format % args}", flush=True)
@@ -235,6 +367,7 @@ class UploadServer(ThreadingHTTPServer):
output_dir_override: Path | None,
upload_token: str,
max_bytes: int,
debounce_seconds: float,
) -> None:
super().__init__(server_address, handler_class)
self.profiles = profiles
@@ -242,6 +375,7 @@ class UploadServer(ThreadingHTTPServer):
self.output_dir_override = output_dir_override
self.upload_token = upload_token
self.max_bytes = max_bytes
self.batch_manager = BatchManager(debounce_seconds)
def apply_legacy_clipboard_overrides(profile: Profile) -> Profile:
@@ -254,6 +388,8 @@ def apply_legacy_clipboard_overrides(profile: Profile) -> Profile:
clipboard = CLIPBOARD_PATH
if env_flag("IPHONE_PHOTO_COPY_TERMINAL_PATH"):
clipboard = CLIPBOARD_TERMINAL_PATH
if env_flag("IPHONE_PHOTO_COPY_FILES"):
clipboard = CLIPBOARD_FILES
notify_enabled = env_flag("IPHONE_PHOTO_NOTIFY", profile.notify)
reveal_enabled = env_flag("IPHONE_PHOTO_REVEAL", profile.reveal)
@@ -268,9 +404,10 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--output-dir", type=Path, default=os.getenv("IPHONE_PHOTO_OUTPUT_DIR"))
parser.add_argument("--token", default=os.getenv("IPHONE_PHOTO_TOKEN", ""))
parser.add_argument("--max-mb", type=int, default=int(os.getenv("IPHONE_PHOTO_MAX_MB", "30")))
parser.add_argument("--debounce-seconds", type=float, default=float(os.getenv("IPHONE_PHOTO_DEBOUNCE_SECONDS", "5")))
parser.add_argument(
"--clipboard",
choices=[CLIPBOARD_NONE, CLIPBOARD_IMAGE, CLIPBOARD_PATH, CLIPBOARD_TERMINAL_PATH, CLIPBOARD_FILE],
choices=[CLIPBOARD_NONE, CLIPBOARD_IMAGE, CLIPBOARD_PATH, CLIPBOARD_TERMINAL_PATH, CLIPBOARD_FILE, CLIPBOARD_FILES],
default=os.getenv("IPHONE_PHOTO_CLIPBOARD"),
)
parser.add_argument("--no-notify", action="store_true", default=env_flag("IPHONE_PHOTO_NO_NOTIFY"))
@@ -319,9 +456,11 @@ def main() -> None:
output_dir_override,
args.token,
args.max_mb * 1024 * 1024,
args.debounce_seconds,
)
print(f"listening on http://{args.host}:{args.port}/upload", flush=True)
print(f"default profile: {default_profile}", flush=True)
print(f"debounce seconds: {args.debounce_seconds:g}", flush=True)
for profile in profiles.values():
output_dir = output_dir_override or profile.output_dir
print(

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env python3
from __future__ import annotations
import importlib.util
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
RECEIVER_PATH = Path(__file__).with_name("receiver.py")
SPEC = importlib.util.spec_from_file_location("iphone_photo_receiver", RECEIVER_PATH)
receiver = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
sys.modules[SPEC.name] = receiver
SPEC.loader.exec_module(receiver)
class ReceiverTests(unittest.TestCase):
def tearDown(self) -> None:
for batch in getattr(self, "batches", []):
if batch.timer is not None:
batch.timer.cancel()
def test_mattermost_profile_defaults_to_files_clipboard(self) -> None:
profile = receiver.profile_defaults()["mattermost"]
self.assertEqual(profile.clipboard, receiver.CLIPBOARD_FILES)
def test_batch_add_accumulates_paths_by_profile(self) -> None:
manager = receiver.BatchManager(debounce_seconds=60)
profile = receiver.Profile("opencode", Path("/tmp"), receiver.CLIPBOARD_TERMINAL_PATH, notify=False)
count1, paths1 = manager.add(profile, Path("/tmp"), Path("/tmp/one.jpg"))
count2, paths2 = manager.add(profile, Path("/tmp"), Path("/tmp/two.jpg"))
self.batches = list(manager.batches.values())
self.assertEqual(count1, 1)
self.assertEqual(paths1, [Path("/tmp/one.jpg")])
self.assertEqual(count2, 2)
self.assertEqual(paths2, [Path("/tmp/one.jpg"), Path("/tmp/two.jpg")])
def test_files_clipboard_passes_all_paths_to_native_helper(self) -> None:
paths = [Path("/tmp/one.jpg"), Path("/tmp/two.jpg")]
with tempfile.NamedTemporaryFile() as helper:
helper_path = Path(helper.name)
with patch.object(receiver, "COPY_FILES_HELPER_BIN", Path("/tmp/missing-helper-bin")), \
patch.object(receiver, "COPY_FILES_HELPER", helper_path), \
patch.object(receiver, "run_macos_action", return_value=True) as run_action:
self.assertTrue(receiver.copy_files_to_clipboard(paths))
run_action.assert_called_once_with([
str(helper_path),
"/tmp/one.jpg",
"/tmp/two.jpg",
])
def test_terminal_paths_clipboard_contains_all_paths(self) -> None:
paths = [Path("/tmp/one.jpg"), Path("/tmp/two with space.jpg")]
with patch.object(receiver, "copy_text_to_clipboard", return_value=True) as copy_text:
self.assertTrue(receiver.apply_clipboard_mode(receiver.CLIPBOARD_TERMINAL_PATH, paths))
copy_text.assert_called_once_with("/tmp/one.jpg\n'/tmp/two with space.jpg'")
if __name__ == "__main__":
unittest.main()