feat: implement iPhone photo inbox receiver for JPEG uploads and add README documentation
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,6 +11,8 @@ __pycache__/
|
|||||||
ai/inbox/mattermost-latest.md
|
ai/inbox/mattermost-latest.md
|
||||||
ai/inbox/mattermost-*.md
|
ai/inbox/mattermost-*.md
|
||||||
ai/inbox/mattermost-status.json
|
ai/inbox/mattermost-status.json
|
||||||
|
ai/inbox/photos/*
|
||||||
|
!ai/inbox/photos/.gitkeep
|
||||||
|
|
||||||
# Workspace-local Mattermost runtime artifacts
|
# Workspace-local Mattermost runtime artifacts
|
||||||
scripts/mattermost/.env
|
scripts/mattermost/.env
|
||||||
|
|||||||
1
ai/inbox/photos/.gitkeep
Normal file
1
ai/inbox/photos/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
132
scripts/iphone-photo-inbox/README.md
Normal file
132
scripts/iphone-photo-inbox/README.md
Normal 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.
|
||||||
141
scripts/iphone-photo-inbox/receiver.py
Executable file
141
scripts/iphone-photo-inbox/receiver.py
Executable 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()
|
||||||
Reference in New Issue
Block a user