diff --git a/.gitignore b/.gitignore index 8c2104b..9670baf 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ __pycache__/ ai/inbox/mattermost-latest.md ai/inbox/mattermost-*.md ai/inbox/mattermost-status.json +ai/inbox/photos/* +!ai/inbox/photos/.gitkeep # Workspace-local Mattermost runtime artifacts scripts/mattermost/.env diff --git a/ai/inbox/photos/.gitkeep b/ai/inbox/photos/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ai/inbox/photos/.gitkeep @@ -0,0 +1 @@ + diff --git a/scripts/iphone-photo-inbox/README.md b/scripts/iphone-photo-inbox/README.md new file mode 100644 index 0000000..0619ad9 --- /dev/null +++ b/scripts/iphone-photo-inbox/README.md @@ -0,0 +1,132 @@ +# 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. + +Default destination: + +```text +ai/inbox/photos/ +``` + +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`. + +## Start the receiver + +OpenCode/workspace inbox: + +```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 +``` + +Find the Mac IP address on the current network: + +```bash +ipconfig getifaddr en0 +``` + +The iPhone Shortcut should send each JPEG to: + +```text +http://MAC_IP:8787/upload?token=choose-a-token +``` + +## Shortcut shape + +### 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: + +```text +Take Photo + Show Camera Preview: On +Get Contents of URL + URL: http://MAC_IP:8787/upload?token=choose-a-token + Method: POST + Request Body: File + File: Photo +Show Notification + Sent to Mac photo inbox +``` + +On the tested iPhone flow, `Take Photo` already produces a JPEG, so the +conversion step is intentionally omitted for the fastest path. + +### Existing Photos flow + +Use this when you want to send existing images from Photos: + +```text +Receive Images and Media from Share Sheet +Repeat with Each Item in Shortcut Input + Convert Image + Image: Repeat Item + Format: JPEG + Get Contents of URL + URL: http://MAC_IP:8787/upload?token=choose-a-token + Method: POST + Request Body: File + File: Converted Image +End Repeat +Show Notification + Sent to Mac photo inbox +``` + +### Semi-automatic Camera.app flow + +iOS does not expose a clean "new photo was taken" automation trigger. The +closest option is a Personal Automation: + +```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 +``` + +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. + +## Usage profiles + +OpenCode analysis: + +- Use the default `ai/inbox/photos/` destination. +- Reference the received file directly from this workspace. +- Treat received files as raw evidence until reviewed. + +Mattermost / Jeff: + +- 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. diff --git a/scripts/iphone-photo-inbox/receiver.py b/scripts/iphone-photo-inbox/receiver.py new file mode 100755 index 0000000..5bf449f --- /dev/null +++ b/scripts/iphone-photo-inbox/receiver.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Receive JPEG uploads from iPhone Shortcuts into the workspace inbox.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import os +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from tempfile import NamedTemporaryFile +from urllib.parse import parse_qs, urlparse + + +DEFAULT_OUTPUT_DIR = Path(__file__).resolve().parents[2] / "ai" / "inbox" / "photos" + + +def timestamp() -> str: + return dt.datetime.now().strftime("%Y%m%d-%H%M%S-%f") + + +def unique_path(output_dir: Path) -> Path: + candidate = output_dir / f"iphone-{timestamp()}.jpg" + counter = 1 + while candidate.exists(): + candidate = output_dir / f"iphone-{timestamp()}-{counter}.jpg" + counter += 1 + return candidate + + +def looks_like_jpeg(data: bytes) -> bool: + return data.startswith(b"\xff\xd8") and data.endswith(b"\xff\xd9") + + +class UploadHandler(BaseHTTPRequestHandler): + server_version = "iPhonePhotoInbox/1.0" + + def do_GET(self) -> None: + if self.path == "/health": + self.send_text(HTTPStatus.OK, "ok\n") + return + self.send_text(HTTPStatus.NOT_FOUND, "not found\n") + + def do_POST(self) -> None: + parsed = urlparse(self.path) + if parsed.path != "/upload": + self.send_text(HTTPStatus.NOT_FOUND, "not found\n") + return + + expected_token = self.server.upload_token + supplied_token = parse_qs(parsed.query).get("token", [""])[0] + if expected_token and supplied_token != expected_token: + self.send_text(HTTPStatus.UNAUTHORIZED, "bad token\n") + return + + content_length = self.headers.get("Content-Length") + if content_length is None: + self.send_text(HTTPStatus.LENGTH_REQUIRED, "missing content length\n") + return + + try: + size = int(content_length) + except ValueError: + self.send_text(HTTPStatus.BAD_REQUEST, "invalid content length\n") + return + + if size <= 0 or size > self.server.max_bytes: + self.send_text(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "invalid upload size\n") + return + + data = self.rfile.read(size) + if not looks_like_jpeg(data): + 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: + tmp.write(data) + temp_path = Path(tmp.name) + temp_path.replace(target) + + self.send_text(HTTPStatus.CREATED, f"{target}\n") + print(f"saved {target}", flush=True) + + def log_message(self, format: str, *args: object) -> None: + print(f"{self.address_string()} - {format % args}", flush=True) + + def send_text(self, status: HTTPStatus, body: str) -> None: + encoded = body.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + +class UploadServer(ThreadingHTTPServer): + def __init__( + self, + server_address: tuple[str, int], + handler_class: type[BaseHTTPRequestHandler], + output_dir: Path, + upload_token: str, + max_bytes: int, + ) -> None: + super().__init__(server_address, handler_class) + self.output_dir = output_dir + self.upload_token = upload_token + self.max_bytes = max_bytes + + +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("--token", default=os.getenv("IPHONE_PHOTO_TOKEN", "")) + parser.add_argument("--max-mb", type=int, default=int(os.getenv("IPHONE_PHOTO_MAX_MB", "30"))) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + server = UploadServer( + (args.host, args.port), + UploadHandler, + args.output_dir.expanduser().resolve(), + args.token, + args.max_mb * 1024 * 1024, + ) + print(f"listening on http://{args.host}:{args.port}/upload", flush=True) + print(f"saving JPEGs to {server.output_dir}", flush=True) + if not args.token: + print("warning: no token configured; anyone on this network can upload", flush=True) + server.serve_forever() + + +if __name__ == "__main__": + main()