feat: enhance iPhone photo inbox receiver with profile management and clipboard options

This commit is contained in:
2026-05-11 12:06:35 -06:00
parent 97ef0be216
commit b5c3ada57d
2 changed files with 296 additions and 153 deletions

View File

@@ -1,123 +1,108 @@
# iPhone Photo Inbox # iPhone Photo Inbox
Local HTTP receiver for sending JPEGs from iPhone Shortcuts into a Mac folder. Local HTTP receiver for sending JPEGs from iPhone Shortcuts into Mac inboxes.
The transport is intentionally generic: the iPhone uploads a JPEG, and the Mac The Shortcut sends a `profile`, and the Mac decides the destination folder and
chooses the destination folder. clipboard behavior.
Default destination: ## Profiles
```text `opencode`
ai/inbox/photos/
```
That default is useful for OpenCode because the images land inside this - Saves to `ai/inbox/photos/`
workspace as raw evidence. For broader use, point the receiver at a neutral - Copies a terminal-safe path to the clipboard
folder such as `~/Pictures/iPhone Inbox`. - 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 ## Start the receiver
OpenCode/workspace inbox: Recommended:
```bash ```bash
IPHONE_PHOTO_TOKEN="choose-a-token" python3 scripts/iphone-photo-inbox/receiver.py IPHONE_PHOTO_TOKEN="choose-a-token" python3 scripts/iphone-photo-inbox/receiver.py
``` ```
General-purpose photo inbox: The receiver listens on:
```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:
```text ```text
notifications enabled http://MAC_IP:8787/upload
clipboard copy enabled
``` ```
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: Find the Mac IP address on the current network:
```bash ```bash
ipconfig getifaddr en0 ipconfig getifaddr en0
``` ```
The iPhone Shortcut should send each JPEG to: If that does not return an IP, use:
```text ```bash
http://MAC_IP:8787/upload?token=choose-a-token ifconfig
``` ```
## Shortcut shape ## Shortcut config
### Fastest reliable flow Use a Dictionary near the top of the Shortcut:
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 ```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 Take Photo
Show Camera Preview: On Show Camera Preview: On
Get Contents of URL Get Contents of URL
URL: http://MAC_IP:8787/upload?token=choose-a-token URL: Text
Method: POST Method: POST
Request Body: File Request Body: File
File: Photo File: Photo
Show Notification Show Notification
Sent to Mac photo inbox Sent to [profile]
``` ```
On the tested iPhone flow, `Take Photo` already produces a JPEG, so the On the tested iPhone flow, `Take Photo` already produces a JPEG, so no
conversion step is intentionally omitted for the fastest path. 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 ```text
Receive Images and Media from Share Sheet Receive Images and Media from Share Sheet
@@ -126,55 +111,84 @@ Repeat with Each Item in Shortcut Input
Image: Repeat Item Image: Repeat Item
Format: JPEG Format: JPEG
Get Contents of URL 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 Method: POST
Request Body: File Request Body: File
File: Converted Image File: Converted Image
End Repeat End Repeat
Show Notification 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 Profile folders:
closest option is a Personal Automation:
```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 ```text
When Camera is Closed profile opencode: dir=... clipboard=terminal-path notify=True reveal=False
Get Latest Photos profile mattermost: dir=... clipboard=image notify=True reveal=False
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 After upload, expect:
Camera without taking a new one. Prefer the Shortcut-owned camera flow when
duplicates would be annoying.
## 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. ```text
- Reference the received file directly from this workspace. clipboard mode applied: image
- Treat received files as raw evidence until reviewed. ```
Mattermost / Jeff: If files arrive but clipboard/notifications do not behave as expected, check:
- Use a neutral destination such as `~/Pictures/iPhone Inbox`. - The Shortcut URL includes the intended `profile=`.
- Attach the latest received JPEG from Mattermost on the Mac. - The receiver log shows the expected profile.
- Keep the same Shortcut and URL; only the Mac receiver destination changes. - macOS Focus/Do Not Disturb is not hiding notifications.
- Terminal/Codex has permission for AppleScript automation if macOS prompts.
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

@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/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 from __future__ import annotations
@@ -7,7 +7,9 @@ import argparse
import datetime as dt import datetime as dt
import json import json
import os import os
import shlex
import subprocess import subprocess
from dataclasses import dataclass
from http import HTTPStatus from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
@@ -15,7 +17,47 @@ from tempfile import NamedTemporaryFile
from urllib.parse import parse_qs, urlparse 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: 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") return data.startswith(b"\xff\xd8") and data.endswith(b"\xff\xd9")
def env_flag(name: str) -> bool: def normalize_profile(value: str) -> str:
return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"} return value.strip().lower() or "opencode"
def run_macos_action(command: list[str]) -> bool: 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)]) 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: def copy_file_to_clipboard(path: Path) -> bool:
script = f""" script = f"""
set theFile to POSIX file {json.dumps(str(path))} as alias set theFile to POSIX file {json.dumps(str(path))} as alias
@@ -73,8 +120,32 @@ end tell
return run_macos_action(["osascript", "-e", script]) 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): class UploadHandler(BaseHTTPRequestHandler):
server_version = "iPhonePhotoInbox/1.0" server_version = "iPhonePhotoInbox/2.0"
def do_GET(self) -> None: def do_GET(self) -> None:
if self.path == "/health": if self.path == "/health":
@@ -88,12 +159,20 @@ class UploadHandler(BaseHTTPRequestHandler):
self.send_text(HTTPStatus.NOT_FOUND, "not found\n") self.send_text(HTTPStatus.NOT_FOUND, "not found\n")
return return
query = parse_qs(parsed.query)
expected_token = self.server.upload_token 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: if expected_token and supplied_token != expected_token:
self.send_text(HTTPStatus.UNAUTHORIZED, "bad token\n") self.send_text(HTTPStatus.UNAUTHORIZED, "bad token\n")
return 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") content_length = self.headers.get("Content-Length")
if content_length is None: if content_length is None:
self.send_text(HTTPStatus.LENGTH_REQUIRED, "missing content length\n") 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") self.send_text(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "expected jpeg\n")
return return
self.server.output_dir.mkdir(parents=True, exist_ok=True) output_dir = self.server.output_dir_override or profile.output_dir
target = unique_path(self.server.output_dir) output_dir.mkdir(parents=True, exist_ok=True)
with NamedTemporaryFile(dir=self.server.output_dir, delete=False) as tmp: target = unique_path(output_dir)
with NamedTemporaryFile(dir=output_dir, delete=False) as tmp:
tmp.write(data) tmp.write(data)
temp_path = Path(tmp.name) temp_path = Path(tmp.name)
temp_path.replace(target) temp_path.replace(target)
if self.server.notify_on_upload: if profile.notify:
if notify("iPhone Photo Inbox", target.name): if notify("iPhone Photo Inbox", f"{profile.name}: {target.name}"):
print("notification sent", flush=True) print("notification sent", flush=True)
if self.server.reveal_on_upload: if profile.reveal:
if reveal_in_finder(target): if reveal_in_finder(target):
print("revealed in Finder", flush=True) print("revealed in Finder", flush=True)
if self.server.copy_on_upload: if apply_clipboard_mode(profile.clipboard, target):
if copy_file_to_clipboard(target): print(f"clipboard mode applied: {profile.clipboard}", flush=True)
print("copied file to clipboard", flush=True)
self.send_text(HTTPStatus.CREATED, f"{target}\n") 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: def log_message(self, format: str, *args: object) -> None:
print(f"{self.address_string()} - {format % args}", flush=True) print(f"{self.address_string()} - {format % args}", flush=True)
@@ -151,55 +230,105 @@ class UploadServer(ThreadingHTTPServer):
self, self,
server_address: tuple[str, int], server_address: tuple[str, int],
handler_class: type[BaseHTTPRequestHandler], handler_class: type[BaseHTTPRequestHandler],
output_dir: Path, profiles: dict[str, Profile],
default_profile: str,
output_dir_override: Path | None,
upload_token: str, upload_token: str,
max_bytes: int, max_bytes: int,
notify_on_upload: bool,
reveal_on_upload: bool,
copy_on_upload: bool,
) -> None: ) -> None:
super().__init__(server_address, handler_class) 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.upload_token = upload_token
self.max_bytes = max_bytes 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: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--host", default=os.getenv("IPHONE_PHOTO_HOST", "0.0.0.0")) 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("--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("--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("--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("--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() 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: def main() -> None:
args = parse_args() 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( server = UploadServer(
(args.host, args.port), (args.host, args.port),
UploadHandler, UploadHandler,
args.output_dir.expanduser().resolve(), profiles,
default_profile,
output_dir_override,
args.token, args.token,
args.max_mb * 1024 * 1024, 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"listening on http://{args.host}:{args.port}/upload", flush=True)
print(f"saving JPEGs to {server.output_dir}", flush=True) print(f"default profile: {default_profile}", flush=True)
if args.notify: for profile in profiles.values():
print("notifications enabled", flush=True) output_dir = output_dir_override or profile.output_dir
if args.reveal: print(
print("Finder reveal enabled", flush=True) f"profile {profile.name}: dir={output_dir.expanduser().resolve()} "
if args.copy: f"clipboard={profile.clipboard} notify={profile.notify} reveal={profile.reveal}",
print("clipboard copy enabled", flush=True) flush=True,
)
if not args.token: if not args.token:
print("warning: no token configured; anyone on this network can upload", flush=True) print("warning: no token configured; anyone on this network can upload", flush=True)
server.serve_forever() server.serve_forever()