diff --git a/scripts/iphone-photo-inbox/README.md b/scripts/iphone-photo-inbox/README.md index f5425e8..cbc071c 100644 --- a/scripts/iphone-photo-inbox/README.md +++ b/scripts/iphone-photo-inbox/README.md @@ -1,123 +1,108 @@ # iPhone Photo Inbox -Local HTTP receiver for sending JPEGs from iPhone Shortcuts into a Mac folder. -The transport is intentionally generic: the iPhone uploads a JPEG, and the Mac -chooses the destination folder. +Local HTTP receiver for sending JPEGs from iPhone Shortcuts into Mac inboxes. +The Shortcut sends a `profile`, and the Mac decides the destination folder and +clipboard behavior. -Default destination: +## Profiles -```text -ai/inbox/photos/ -``` +`opencode` -That default is useful for OpenCode because the images land inside this -workspace as raw evidence. For broader use, point the receiver at a neutral -folder such as `~/Pictures/iPhone Inbox`. +- Saves to `ai/inbox/photos/` +- Copies a terminal-safe path 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 + +`general` + +- Saves to `~/Pictures/iPhone Inbox` +- Does not modify the clipboard +- Useful for plain capture + +All profiles show a macOS notification by default. ## Start the receiver -OpenCode/workspace inbox: +Recommended: ```bash IPHONE_PHOTO_TOKEN="choose-a-token" python3 scripts/iphone-photo-inbox/receiver.py ``` -General-purpose photo inbox: - -```bash -IPHONE_PHOTO_TOKEN="choose-a-token" \ -IPHONE_PHOTO_OUTPUT_DIR="$HOME/Pictures/iPhone Inbox" \ -python3 scripts/iphone-photo-inbox/receiver.py -``` - -Useful receive modes: - -```bash -# Show a macOS notification when a photo arrives. -IPHONE_PHOTO_NOTIFY=1 IPHONE_PHOTO_TOKEN="choose-a-token" python3 scripts/iphone-photo-inbox/receiver.py - -# Reveal each received photo in Finder. -IPHONE_PHOTO_REVEAL=1 IPHONE_PHOTO_TOKEN="choose-a-token" python3 scripts/iphone-photo-inbox/receiver.py - -# Copy each received photo file to the Mac clipboard for pasting into apps. -IPHONE_PHOTO_COPY=1 IPHONE_PHOTO_TOKEN="choose-a-token" python3 scripts/iphone-photo-inbox/receiver.py -``` - -These can be combined: - -```bash -IPHONE_PHOTO_NOTIFY=1 \ -IPHONE_PHOTO_COPY=1 \ -IPHONE_PHOTO_OUTPUT_DIR="$HOME/Pictures/iPhone Inbox" \ -IPHONE_PHOTO_TOKEN="choose-a-token" \ -python3 scripts/iphone-photo-inbox/receiver.py -``` - -The flags also accept `true`, `yes`, or `on`: - -```bash -IPHONE_PHOTO_NOTIFY=true IPHONE_PHOTO_COPY=true ... -``` - -When these modes are active, the receiver startup log should include: +The receiver listens on: ```text -notifications enabled -clipboard copy enabled +http://MAC_IP:8787/upload ``` -After each upload, it should also print: - -```text -notification sent -copied file to clipboard -``` - -If those startup lines do not appear, the environment variables were not passed -to the running receiver process. If the startup lines appear but the post-upload -lines do not, check the printed `macOS action failed:` error and macOS privacy -permissions for Terminal/Codex automation and notifications. - Find the Mac IP address on the current network: ```bash ipconfig getifaddr en0 ``` -The iPhone Shortcut should send each JPEG to: +If that does not return an IP, use: -```text -http://MAC_IP:8787/upload?token=choose-a-token +```bash +ifconfig ``` -## Shortcut shape +## Shortcut config -### Fastest reliable flow - -Put this Shortcut on the Home Screen, Lock Screen, Action Button, or Back Tap. -This is the most reliable "take photo and send immediately" flow because the -Shortcut owns the capture and upload sequence. - -Use this when you want the camera itself to be the capture flow: +Use a Dictionary near the top of the Shortcut: ```text +mac_ip: 192.168.11.186 +port: 8787 +token: choose-a-token +profile: opencode +``` + +Build the URL from the dictionary: + +```text +http://[mac_ip]:[port]/upload?token=[token]&profile=[profile] +``` + +Use `profile: opencode` when the next paste target is OpenCode. Use +`profile: mattermost` when the next paste target is Mattermost. + +## Camera shortcut + +```text +Dictionary + mac_ip: 192.168.11.186 + port: 8787 + token: choose-a-token + profile: opencode + +Text + http://[mac_ip]:[port]/upload?token=[token]&profile=[profile] + Take Photo Show Camera Preview: On + Get Contents of URL - URL: http://MAC_IP:8787/upload?token=choose-a-token + URL: Text Method: POST Request Body: File File: Photo + Show Notification - Sent to Mac photo inbox + Sent to [profile] ``` -On the tested iPhone flow, `Take Photo` already produces a JPEG, so the -conversion step is intentionally omitted for the fastest path. +On the tested iPhone flow, `Take Photo` already produces a JPEG, so no +conversion step is needed. -### Existing Photos flow +## Existing photos shortcut -Use this when you want to send existing images from Photos: +Use this when sending existing images from Photos: ```text Receive Images and Media from Share Sheet @@ -126,55 +111,84 @@ Repeat with Each Item in Shortcut Input Image: Repeat Item Format: JPEG Get Contents of URL - URL: http://MAC_IP:8787/upload?token=choose-a-token + URL: http://[mac_ip]:[port]/upload?token=[token]&profile=[profile] Method: POST Request Body: File File: Converted Image End Repeat Show Notification - Sent to Mac photo inbox + Sent to [profile] ``` -### Semi-automatic Camera.app flow +## Overrides -iOS does not expose a clean "new photo was taken" automation trigger. The -closest option is a Personal Automation: +Profile folders: + +```bash +IPHONE_PHOTO_OPENCODE_DIR="/path/to/opencode/photos" +IPHONE_PHOTO_MATTERMOST_DIR="$HOME/Pictures/iPhone Inbox" +IPHONE_PHOTO_GENERAL_DIR="$HOME/Pictures/iPhone Inbox" +``` + +Global folder override for all profiles: + +```bash +IPHONE_PHOTO_OUTPUT_DIR="$HOME/Pictures/iPhone Inbox" \ +IPHONE_PHOTO_TOKEN="choose-a-token" \ +python3 scripts/iphone-photo-inbox/receiver.py +``` + +Default profile when the URL does not include `profile=`: + +```bash +IPHONE_PHOTO_PROFILE=mattermost \ +IPHONE_PHOTO_TOKEN="choose-a-token" \ +python3 scripts/iphone-photo-inbox/receiver.py +``` + +Clipboard override for all profiles: + +```bash +IPHONE_PHOTO_CLIPBOARD=image +IPHONE_PHOTO_CLIPBOARD=terminal-path +IPHONE_PHOTO_CLIPBOARD=path +IPHONE_PHOTO_CLIPBOARD=file +IPHONE_PHOTO_CLIPBOARD=none +``` + +Other useful options: + +```bash +python3 scripts/iphone-photo-inbox/receiver.py --no-notify +python3 scripts/iphone-photo-inbox/receiver.py --reveal +``` + +## Troubleshooting + +Startup should print each active profile: ```text -When Camera is Closed - Get Latest Photos - Include Screenshots: Off - Limit: 1 - Convert Image - Format: JPEG - Get Contents of URL - URL: http://MAC_IP:8787/upload?token=choose-a-token - Method: POST - Request Body: File - File: Converted Image +profile opencode: dir=... clipboard=terminal-path notify=True reveal=False +profile mattermost: dir=... clipboard=image notify=True reveal=False ``` -This is convenient, but it can resend the latest photo if you open and close -Camera without taking a new one. Prefer the Shortcut-owned camera flow when -duplicates would be annoying. +After upload, expect: -## Usage profiles +```text +notification sent +clipboard mode applied: terminal-path +saved ... profile=opencode +``` -OpenCode analysis: +For Mattermost, expect: -- Use the default `ai/inbox/photos/` destination. -- Reference the received file directly from this workspace. -- Treat received files as raw evidence until reviewed. +```text +clipboard mode applied: image +``` -Mattermost / Jeff: +If files arrive but clipboard/notifications do not behave as expected, check: -- Use a neutral destination such as `~/Pictures/iPhone Inbox`. -- Attach the latest received JPEG from Mattermost on the Mac. -- Keep the same Shortcut and URL; only the Mac receiver destination changes. - -General capture: - -- Use the neutral destination when the photo is not specifically workspace - evidence. -- Keep JPEG validation enabled in the receiver so downstream tools get a - predictable format. +- The Shortcut URL includes the intended `profile=`. +- The receiver log shows the expected profile. +- 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/receiver.py b/scripts/iphone-photo-inbox/receiver.py index 858af5b..92ccb2f 100755 --- a/scripts/iphone-photo-inbox/receiver.py +++ b/scripts/iphone-photo-inbox/receiver.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Receive JPEG uploads from iPhone Shortcuts into the workspace inbox.""" +"""Receive JPEG uploads from iPhone Shortcuts into local Mac inboxes.""" from __future__ import annotations @@ -7,7 +7,9 @@ import argparse import datetime as dt import json import os +import shlex import subprocess +from dataclasses import dataclass from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -15,7 +17,47 @@ from tempfile import NamedTemporaryFile from urllib.parse import parse_qs, urlparse -DEFAULT_OUTPUT_DIR = Path(__file__).resolve().parents[2] / "ai" / "inbox" / "photos" +WORKSPACE_DIR = Path(__file__).resolve().parents[2] +DEFAULT_OPENCODE_DIR = WORKSPACE_DIR / "ai" / "inbox" / "photos" +DEFAULT_MATTERMOST_DIR = Path.home() / "Pictures" / "iPhone Inbox" + +CLIPBOARD_NONE = "none" +CLIPBOARD_IMAGE = "image" +CLIPBOARD_PATH = "path" +CLIPBOARD_TERMINAL_PATH = "terminal-path" +CLIPBOARD_FILE = "file" + + +@dataclass(frozen=True) +class Profile: + name: str + output_dir: Path + clipboard: str + notify: bool = True + reveal: bool = False + + +def env_flag(name: str, default: bool = False) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def env_path(name: str, default: Path) -> Path: + value = os.getenv(name) + return Path(value).expanduser() if value else default + + +def profile_defaults() -> dict[str, Profile]: + opencode_dir = env_path("IPHONE_PHOTO_OPENCODE_DIR", DEFAULT_OPENCODE_DIR) + mattermost_dir = env_path("IPHONE_PHOTO_MATTERMOST_DIR", DEFAULT_MATTERMOST_DIR) + 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), + "general": Profile("general", general_dir, CLIPBOARD_NONE), + } def timestamp() -> str: @@ -35,8 +77,8 @@ def looks_like_jpeg(data: bytes) -> bool: return data.startswith(b"\xff\xd8") and data.endswith(b"\xff\xd9") -def env_flag(name: str) -> bool: - return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"} +def normalize_profile(value: str) -> str: + return value.strip().lower() or "opencode" def run_macos_action(command: list[str]) -> bool: @@ -63,6 +105,11 @@ def reveal_in_finder(path: Path) -> bool: return run_macos_action(["open", "-R", str(path)]) +def copy_image_to_clipboard(path: Path) -> bool: + script = f"set the clipboard to (read (POSIX file {json.dumps(str(path))}) as JPEG picture)" + return run_macos_action(["osascript", "-e", script]) + + def copy_file_to_clipboard(path: Path) -> bool: script = f""" set theFile to POSIX file {json.dumps(str(path))} as alias @@ -73,8 +120,32 @@ 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]) + + +def terminal_path_reference(path: Path) -> str: + return shlex.quote(str(path)) + + +def apply_clipboard_mode(mode: str, path: Path) -> bool: + if mode == CLIPBOARD_NONE: + return True + if mode == CLIPBOARD_IMAGE: + return copy_image_to_clipboard(path) + if mode == CLIPBOARD_PATH: + return copy_text_to_clipboard(str(path)) + if mode == CLIPBOARD_TERMINAL_PATH: + return copy_text_to_clipboard(terminal_path_reference(path)) + if mode == CLIPBOARD_FILE: + return copy_file_to_clipboard(path) + print(f"unsupported clipboard mode: {mode}", flush=True) + return False + + class UploadHandler(BaseHTTPRequestHandler): - server_version = "iPhonePhotoInbox/1.0" + server_version = "iPhonePhotoInbox/2.0" def do_GET(self) -> None: if self.path == "/health": @@ -88,12 +159,20 @@ class UploadHandler(BaseHTTPRequestHandler): self.send_text(HTTPStatus.NOT_FOUND, "not found\n") return + query = parse_qs(parsed.query) expected_token = self.server.upload_token - supplied_token = parse_qs(parsed.query).get("token", [""])[0] + supplied_token = query.get("token", [""])[0] if expected_token and supplied_token != expected_token: self.send_text(HTTPStatus.UNAUTHORIZED, "bad token\n") return + profile_name = normalize_profile(query.get("profile", [self.server.default_profile])[0]) + profile = self.server.profiles.get(profile_name) + if profile is None: + known = ", ".join(sorted(self.server.profiles)) + self.send_text(HTTPStatus.BAD_REQUEST, f"unknown profile: {profile_name}; expected one of {known}\n") + return + content_length = self.headers.get("Content-Length") if content_length is None: self.send_text(HTTPStatus.LENGTH_REQUIRED, "missing content length\n") @@ -114,25 +193,25 @@ class UploadHandler(BaseHTTPRequestHandler): self.send_text(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "expected jpeg\n") return - self.server.output_dir.mkdir(parents=True, exist_ok=True) - target = unique_path(self.server.output_dir) - with NamedTemporaryFile(dir=self.server.output_dir, delete=False) as tmp: + output_dir = self.server.output_dir_override or profile.output_dir + output_dir.mkdir(parents=True, exist_ok=True) + target = unique_path(output_dir) + with NamedTemporaryFile(dir=output_dir, delete=False) as tmp: tmp.write(data) temp_path = Path(tmp.name) temp_path.replace(target) - if self.server.notify_on_upload: - if notify("iPhone Photo Inbox", target.name): + if profile.notify: + if notify("iPhone Photo Inbox", f"{profile.name}: {target.name}"): print("notification sent", flush=True) - if self.server.reveal_on_upload: + if profile.reveal: if reveal_in_finder(target): print("revealed in Finder", flush=True) - if self.server.copy_on_upload: - if copy_file_to_clipboard(target): - print("copied file to clipboard", flush=True) + if apply_clipboard_mode(profile.clipboard, target): + print(f"clipboard mode applied: {profile.clipboard}", flush=True) self.send_text(HTTPStatus.CREATED, f"{target}\n") - print(f"saved {target}", flush=True) + print(f"saved {target} profile={profile.name}", flush=True) def log_message(self, format: str, *args: object) -> None: print(f"{self.address_string()} - {format % args}", flush=True) @@ -151,55 +230,105 @@ class UploadServer(ThreadingHTTPServer): self, server_address: tuple[str, int], handler_class: type[BaseHTTPRequestHandler], - output_dir: Path, + profiles: dict[str, Profile], + default_profile: str, + output_dir_override: Path | None, upload_token: str, max_bytes: int, - notify_on_upload: bool, - reveal_on_upload: bool, - copy_on_upload: bool, ) -> None: super().__init__(server_address, handler_class) - self.output_dir = output_dir + self.profiles = profiles + self.default_profile = default_profile + self.output_dir_override = output_dir_override self.upload_token = upload_token self.max_bytes = max_bytes - self.notify_on_upload = notify_on_upload - self.reveal_on_upload = reveal_on_upload - self.copy_on_upload = copy_on_upload + + +def apply_legacy_clipboard_overrides(profile: Profile) -> Profile: + clipboard = profile.clipboard + if env_flag("IPHONE_PHOTO_COPY"): + clipboard = CLIPBOARD_IMAGE + if env_flag("IPHONE_PHOTO_COPY_FILE"): + clipboard = CLIPBOARD_FILE + if env_flag("IPHONE_PHOTO_COPY_PATH"): + clipboard = CLIPBOARD_PATH + if env_flag("IPHONE_PHOTO_COPY_TERMINAL_PATH"): + clipboard = CLIPBOARD_TERMINAL_PATH + + notify_enabled = env_flag("IPHONE_PHOTO_NOTIFY", profile.notify) + reveal_enabled = env_flag("IPHONE_PHOTO_REVEAL", profile.reveal) + return Profile(profile.name, profile.output_dir, clipboard, notify_enabled, reveal_enabled) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--host", default=os.getenv("IPHONE_PHOTO_HOST", "0.0.0.0")) parser.add_argument("--port", type=int, default=int(os.getenv("IPHONE_PHOTO_PORT", "8787"))) - parser.add_argument("--output-dir", type=Path, default=Path(os.getenv("IPHONE_PHOTO_OUTPUT_DIR", DEFAULT_OUTPUT_DIR))) + parser.add_argument("--profile", default=os.getenv("IPHONE_PHOTO_PROFILE", "opencode")) + 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("--notify", action="store_true", default=env_flag("IPHONE_PHOTO_NOTIFY")) + parser.add_argument( + "--clipboard", + choices=[CLIPBOARD_NONE, CLIPBOARD_IMAGE, CLIPBOARD_PATH, CLIPBOARD_TERMINAL_PATH, CLIPBOARD_FILE], + default=os.getenv("IPHONE_PHOTO_CLIPBOARD"), + ) + parser.add_argument("--no-notify", action="store_true", default=env_flag("IPHONE_PHOTO_NO_NOTIFY")) parser.add_argument("--reveal", action="store_true", default=env_flag("IPHONE_PHOTO_REVEAL")) - parser.add_argument("--copy", action="store_true", default=env_flag("IPHONE_PHOTO_COPY")) return parser.parse_args() +def build_profiles(args: argparse.Namespace) -> dict[str, Profile]: + profiles = { + name: apply_legacy_clipboard_overrides(profile) + for name, profile in profile_defaults().items() + } + + if args.clipboard: + profiles = { + name: Profile(profile.name, profile.output_dir, args.clipboard, profile.notify, profile.reveal) + for name, profile in profiles.items() + } + if args.no_notify: + profiles = { + name: Profile(profile.name, profile.output_dir, profile.clipboard, False, profile.reveal) + for name, profile in profiles.items() + } + if args.reveal: + profiles = { + name: Profile(profile.name, profile.output_dir, profile.clipboard, profile.notify, True) + for name, profile in profiles.items() + } + return profiles + + def main() -> None: args = parse_args() + profiles = build_profiles(args) + default_profile = normalize_profile(args.profile) + if default_profile not in profiles: + known = ", ".join(sorted(profiles)) + raise SystemExit(f"unknown default profile: {default_profile}; expected one of {known}") + + output_dir_override = Path(args.output_dir).expanduser().resolve() if args.output_dir else None server = UploadServer( (args.host, args.port), UploadHandler, - args.output_dir.expanduser().resolve(), + profiles, + default_profile, + output_dir_override, args.token, args.max_mb * 1024 * 1024, - args.notify, - args.reveal, - args.copy, ) print(f"listening on http://{args.host}:{args.port}/upload", flush=True) - print(f"saving JPEGs to {server.output_dir}", flush=True) - if args.notify: - print("notifications enabled", flush=True) - if args.reveal: - print("Finder reveal enabled", flush=True) - if args.copy: - print("clipboard copy enabled", flush=True) + print(f"default profile: {default_profile}", flush=True) + for profile in profiles.values(): + output_dir = output_dir_override or profile.output_dir + print( + f"profile {profile.name}: dir={output_dir.expanduser().resolve()} " + f"clipboard={profile.clipboard} notify={profile.notify} reveal={profile.reveal}", + flush=True, + ) if not args.token: print("warning: no token configured; anyone on this network can upload", flush=True) server.serve_forever()