diff --git a/.gitignore b/.gitignore index 9670baf..c142c5f 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ ai/inbox/mattermost-status.json ai/inbox/photos/* !ai/inbox/photos/.gitkeep +# Local build artifact for the iPhone photo inbox pasteboard helper +scripts/iphone-photo-inbox/copy_files_to_clipboard + # Workspace-local Mattermost runtime artifacts scripts/mattermost/.env scripts/mattermost/.venv/ diff --git a/scripts/iphone-photo-inbox/README.md b/scripts/iphone-photo-inbox/README.md index cbc071c..2b4e51a 100644 --- a/scripts/iphone-photo-inbox/README.md +++ b/scripts/iphone-photo-inbox/README.md @@ -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. diff --git a/scripts/iphone-photo-inbox/copy_files_to_clipboard.swift b/scripts/iphone-photo-inbox/copy_files_to_clipboard.swift new file mode 100755 index 0000000..880eb9a --- /dev/null +++ b/scripts/iphone-photo-inbox/copy_files_to_clipboard.swift @@ -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...]\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) diff --git a/scripts/iphone-photo-inbox/receiver.py b/scripts/iphone-photo-inbox/receiver.py index 92ccb2f..cb21dd6 100755 --- a/scripts/iphone-photo-inbox/receiver.py +++ b/scripts/iphone-photo-inbox/receiver.py @@ -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( diff --git a/scripts/iphone-photo-inbox/test_receiver.py b/scripts/iphone-photo-inbox/test_receiver.py new file mode 100644 index 0000000..6aa67b2 --- /dev/null +++ b/scripts/iphone-photo-inbox/test_receiver.py @@ -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()