feat: implement iPhone photo inbox receiver for JPEG uploads and add README documentation

This commit is contained in:
2026-05-11 10:08:04 -06:00
parent 1c7e18d7c1
commit e01b59c065
4 changed files with 276 additions and 0 deletions

View File

@@ -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.

View File

@@ -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()