feat: update iPhone photo inbox scripts with environment file support and refactor batch handling

This commit is contained in:
2026-05-15 08:43:19 -06:00
parent 456a4c3381
commit 8950cfcdf0
6 changed files with 288 additions and 391 deletions

1
.gitignore vendored
View File

@@ -15,6 +15,7 @@ ai/inbox/photos/*
!ai/inbox/photos/.gitkeep !ai/inbox/photos/.gitkeep
# Local build artifact for the iPhone photo inbox pasteboard helper # Local build artifact for the iPhone photo inbox pasteboard helper
scripts/iphone-photo-inbox/.env
scripts/iphone-photo-inbox/copy_files_to_clipboard scripts/iphone-photo-inbox/copy_files_to_clipboard
# Workspace-local Mattermost runtime artifacts # Workspace-local Mattermost runtime artifacts

View File

@@ -0,0 +1,13 @@
# Copy this file to scripts/iphone-photo-inbox/.env and adjust local values.
PHOTO_INBOX_TOKEN=choose-a-token
PHOTO_INBOX_HOST=0.0.0.0
PHOTO_INBOX_PORT=8787
PHOTO_INBOX_DIR=/Users/david/Pictures/Photo Inbox
PHOTO_INBOX_DEBOUNCE_SECONDS=5
PHOTO_INBOX_CLIPBOARD=1
PHOTO_INBOX_NOTIFY=1
# Optional behavior
# PHOTO_INBOX_REVEAL=1
# PHOTO_INBOX_DEBUG=1

View File

@@ -1,61 +1,37 @@
# iPhone Photo Inbox # Photo Inbox
Local HTTP receiver for sending JPEGs from iPhone Shortcuts into Mac inboxes. macOS HTTP receiver for sending JPEG uploads into a local photo inbox. Clients
The Shortcut sends a `profile`, and the Mac decides the destination folder and can be iPhone Shortcuts, curl, another phone, a script, or any system that can
clipboard behavior. POST a JPEG file. The server currently supports macOS only because clipboard,
Finder reveal, and notifications use macOS APIs/tools.
## Profiles By default, each upload is saved locally and the current batch is copied to the
macOS clipboard as native file URLs.
`opencode` ## Behavior
- Saves to `ai/inbox/photos/` - Saves photos to `~/Pictures/Photo Inbox` by default.
- Copies terminal-safe paths to the clipboard - Groups consecutive uploads into a batch.
- Best for pasting into OpenCode running in a terminal - Every new photo extends the batch by `5s`.
- Every new photo immediately refreshes the clipboard with the full batch.
- When no new photo arrives before debounce expires, a summary notification is shown.
`mattermost` This uses a small Swift helper and `NSPasteboard.writeObjects`, which matches
Finder-style file clipboard behavior.
- Saves to `~/Pictures/iPhone Inbox`
- Copies native macOS file URLs to the clipboard
- Best effort for pasting one or more files 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.
## Batch debounce
Uploads are grouped by profile. Every new photo extends the active profile batch
by 10 seconds and immediately refreshes the clipboard with the full batch.
Example for `opencode`:
```text
photo 1 arrives -> clipboard has photo 1 path
photo 2 arrives within 10s -> clipboard has photo 1 + photo 2 paths
photo 3 arrives within 10s -> clipboard has photo 1 + photo 2 + photo 3 paths
10s pass with no new photo -> notification says the batch is ready
```
This keeps the Shortcut simple while still making the clipboard usable before
the final notification appears.
For `mattermost`, each upload rewrites the clipboard with the full batch using a
small Swift helper and `NSPasteboard.writeObjects`. This is closer to Finder's
file-copy behavior than the older AppleScript alias approach. If the native
helper fails, the receiver falls back to AppleScript and logs the fallback.
## Start the receiver ## Start the receiver
Recommended: Recommended:
```bash ```bash
IPHONE_PHOTO_TOKEN="choose-a-token" python3 scripts/iphone-photo-inbox/receiver.py cp scripts/iphone-photo-inbox/.env.example scripts/iphone-photo-inbox/.env
scripts/iphone-photo-inbox/run.sh
``` ```
`.env` is loaded automatically and does not override variables already exported
in the shell. `run.sh` compiles the native pasteboard helper when it is missing
or older than the Swift source.
The receiver listens on: The receiver listens on:
```text ```text
@@ -74,7 +50,19 @@ If that does not return an IP, use:
ifconfig ifconfig
``` ```
## Shortcut config ## Generic client contract
Send a JPEG request body:
```bash
curl --request POST \
--data-binary @photo.jpg \
"http://MAC_IP:8787/upload?token=choose-a-token"
```
Successful response body is the saved local file path.
## iPhone Shortcuts guide
Use a Dictionary near the top of the Shortcut: Use a Dictionary near the top of the Shortcut:
@@ -82,29 +70,24 @@ Use a Dictionary near the top of the Shortcut:
mac_ip: 192.168.11.186 mac_ip: 192.168.11.186
port: 8787 port: 8787
token: choose-a-token token: choose-a-token
profile: opencode
``` ```
Build the URL from the dictionary: Build the URL from the dictionary:
```text ```text
http://[mac_ip]:[port]/upload?token=[token]&profile=[profile] http://[mac_ip]:[port]/upload?token=[token]
``` ```
Use `profile: opencode` when the next paste target is OpenCode. Use Camera Shortcut:
`profile: mattermost` when the next paste target is Mattermost.
## Camera shortcut
```text ```text
Dictionary Dictionary
mac_ip: 192.168.11.186 mac_ip: 192.168.11.186
port: 8787 port: 8787
token: choose-a-token token: choose-a-token
profile: opencode
Text Text
http://[mac_ip]:[port]/upload?token=[token]&profile=[profile] http://[mac_ip]:[port]/upload?token=[token]
Take Photo Take Photo
Show Camera Preview: On Show Camera Preview: On
@@ -116,15 +99,13 @@ Get Contents of URL
File: Photo File: Photo
Show Notification Show Notification
Sent to [profile] Sent to photo inbox
``` ```
On the tested iPhone flow, `Take Photo` already produces a JPEG, so no On the tested iPhone flow, `Take Photo` already produces a JPEG, so no
conversion step is needed. conversion step is needed.
## Existing photos shortcut Existing Photos Shortcut:
Use this when sending existing images from Photos:
```text ```text
Receive Images and Media from Share Sheet Receive Images and Media from Share Sheet
@@ -133,87 +114,88 @@ 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]:[port]/upload?token=[token]&profile=[profile] URL: http://[mac_ip]:[port]/upload?token=[token]
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 [profile] Sent to photo inbox
``` ```
## Overrides ## Configuration
Profile folders: Common `.env` values:
```bash ```bash
IPHONE_PHOTO_OPENCODE_DIR="/path/to/opencode/photos" PHOTO_INBOX_TOKEN=choose-a-token
IPHONE_PHOTO_MATTERMOST_DIR="$HOME/Pictures/iPhone Inbox" PHOTO_INBOX_HOST=0.0.0.0
IPHONE_PHOTO_GENERAL_DIR="$HOME/Pictures/iPhone Inbox" PHOTO_INBOX_PORT=8787
``` PHOTO_INBOX_DIR=/Users/david/Pictures/Photo Inbox
PHOTO_INBOX_DEBOUNCE_SECONDS=5
Global folder override for all profiles: PHOTO_INBOX_CLIPBOARD=1
PHOTO_INBOX_NOTIFY=1
```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=files
IPHONE_PHOTO_CLIPBOARD=terminal-path
IPHONE_PHOTO_CLIPBOARD=path
IPHONE_PHOTO_CLIPBOARD=file
IPHONE_PHOTO_CLIPBOARD=none
```
Debounce override:
```bash
IPHONE_PHOTO_DEBOUNCE_SECONDS=5
``` ```
Other useful options: Other useful options:
```bash ```bash
python3 scripts/iphone-photo-inbox/receiver.py --no-notify scripts/iphone-photo-inbox/run.sh --no-clipboard
python3 scripts/iphone-photo-inbox/receiver.py --reveal scripts/iphone-photo-inbox/run.sh --no-notify
IPHONE_PHOTO_DEBUG=1 python3 scripts/iphone-photo-inbox/receiver.py scripts/iphone-photo-inbox/run.sh --reveal
PHOTO_INBOX_DEBUG=1 scripts/iphone-photo-inbox/run.sh
``` ```
## Project integration
Keep this utility as an isolated image mailbox. If a project wants easy access,
link the project inbox to the mailbox instead of making this utility know about
the project.
Example:
```bash
mkdir -p ai/inbox
ln -s "$HOME/Pictures/Photo Inbox" ai/inbox/photos
```
Or point the receiver at a project-owned folder from `.env`:
```bash
PHOTO_INBOX_DIR=/absolute/path/to/project/ai/inbox/photos
```
The symlink approach keeps this utility reusable across projects and devices.
## Troubleshooting ## Troubleshooting
Startup should print each active profile: Startup should print:
```text ```text
profile opencode: dir=... clipboard=terminal-path notify=True reveal=False saving to: ...
profile mattermost: dir=... clipboard=files notify=True reveal=False debounce seconds: 5
clipboard: True
notify: True
``` ```
After each upload, expect: After each upload, expect:
```text ```text
clipboard mode applied: terminal-path profile=opencode count=2 clipboard updated count=2
saved ... profile=opencode batch_count=2 saved ... batch_count=2
``` ```
After the debounce window closes, expect: After the debounce window closes, expect:
```text ```text
batch notification sent profile=opencode count=2 batch notification sent count=2
batch finalized profile=opencode count=2 dir=... batch finalized count=2 dir=...
```
With `PHOTO_INBOX_DEBUG=1`, a two-photo batch should report:
```text
pasteboard files=2 items=2
``` ```
The native file clipboard helper lives at: The native file clipboard helper lives at:
@@ -222,20 +204,8 @@ The native file clipboard helper lives at:
scripts/iphone-photo-inbox/copy_files_to_clipboard.swift scripts/iphone-photo-inbox/copy_files_to_clipboard.swift
``` ```
For faster multi-file clipboard updates during debounce, compile it once: The compiled binary is ignored by git and generated by `run.sh`:
```bash ```text
swiftc scripts/iphone-photo-inbox/copy_files_to_clipboard.swift \ scripts/iphone-photo-inbox/copy_files_to_clipboard
-o scripts/iphone-photo-inbox/copy_files_to_clipboard
``` ```
The compiled binary is ignored by git. If it is not present, the receiver can
run the Swift script directly, but that is slower on first use.
If files arrive but clipboard/notifications do not behave as expected, check:
- The Shortcut URL includes the intended `profile=`.
- The receiver log shows the expected profile.
- With `IPHONE_PHOTO_DEBUG=1`, the Mattermost profile should report `pasteboard files=2 items=2` after the second photo in a two-photo batch.
- macOS Focus/Do Not Disturb is not hiding notifications.
- Terminal/Codex has permission for AppleScript automation if macOS prompts.

384
scripts/iphone-photo-inbox/receiver.py Executable file → Normal file
View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Receive JPEG uploads from iPhone Shortcuts into local Mac inboxes.""" """Receive JPEG uploads into a local Mac photo inbox."""
from __future__ import annotations from __future__ import annotations
@@ -7,7 +7,6 @@ import argparse
import datetime as dt import datetime as dt
import json import json
import os import os
import shlex
import subprocess import subprocess
import threading import threading
from dataclasses import dataclass from dataclasses import dataclass
@@ -18,28 +17,32 @@ from tempfile import NamedTemporaryFile
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
WORKSPACE_DIR = Path(__file__).resolve().parents[2]
SCRIPT_DIR = Path(__file__).resolve().parent SCRIPT_DIR = Path(__file__).resolve().parent
COPY_FILES_HELPER = SCRIPT_DIR / "copy_files_to_clipboard.swift" COPY_FILES_HELPER = SCRIPT_DIR / "copy_files_to_clipboard.swift"
COPY_FILES_HELPER_BIN = SCRIPT_DIR / "copy_files_to_clipboard" COPY_FILES_HELPER_BIN = SCRIPT_DIR / "copy_files_to_clipboard"
DEFAULT_OPENCODE_DIR = WORKSPACE_DIR / "ai" / "inbox" / "photos" DEFAULT_ENV_FILE = SCRIPT_DIR / ".env"
DEFAULT_MATTERMOST_DIR = Path.home() / "Pictures" / "iPhone Inbox" DEFAULT_OUTPUT_DIR = Path.home() / "Pictures" / "Photo Inbox"
CLIPBOARD_NONE = "none"
CLIPBOARD_IMAGE = "image"
CLIPBOARD_PATH = "path"
CLIPBOARD_TERMINAL_PATH = "terminal-path"
CLIPBOARD_FILE = "file"
CLIPBOARD_FILES = "files"
@dataclass(frozen=True) @dataclass(frozen=True)
class Profile: class Config:
name: str
output_dir: Path output_dir: Path
clipboard: str clipboard: bool
notify: bool = True notify: bool
reveal: bool = False reveal: bool
@dataclass
class Batch:
output_dir: Path
paths: list[Path]
started_at: dt.datetime
updated_at: dt.datetime
timer: threading.Timer | None = None
@property
def count(self) -> int:
return len(self.paths)
def env_flag(name: str, default: bool = False) -> bool: def env_flag(name: str, default: bool = False) -> bool:
@@ -49,20 +52,62 @@ def env_flag(name: str, default: bool = False) -> bool:
return value.strip().lower() in {"1", "true", "yes", "on"} return value.strip().lower() in {"1", "true", "yes", "on"}
def env_value(name: str) -> str | None:
return os.getenv(name)
def env_flag_value(name: str, default: bool = False) -> bool:
value = env_value(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def env_path(name: str, default: Path) -> Path: def env_path(name: str, default: Path) -> Path:
value = os.getenv(name) value = env_value(name)
return Path(value).expanduser() if value else default return Path(value).expanduser() if value else default
def profile_defaults() -> dict[str, Profile]: def env_str(name: str, default: str) -> str:
opencode_dir = env_path("IPHONE_PHOTO_OPENCODE_DIR", DEFAULT_OPENCODE_DIR) value = env_value(name)
mattermost_dir = env_path("IPHONE_PHOTO_MATTERMOST_DIR", DEFAULT_MATTERMOST_DIR) return value if value is not None else default
general_dir = env_path("IPHONE_PHOTO_GENERAL_DIR", DEFAULT_MATTERMOST_DIR)
return {
"opencode": Profile("opencode", opencode_dir, CLIPBOARD_TERMINAL_PATH), def env_int(name: str, default: int) -> int:
"mattermost": Profile("mattermost", mattermost_dir, CLIPBOARD_FILES), return int(env_str(name, str(default)))
"general": Profile("general", general_dir, CLIPBOARD_NONE),
}
def env_float(name: str, default: float) -> float:
return float(env_str(name, str(default)))
def parse_env_line(line: str) -> tuple[str, str] | None:
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
return None
key, value = stripped.split("=", 1)
key = key.strip()
value = value.strip()
if not key:
return None
if key.startswith("export "):
key = key.removeprefix("export ").strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
value = value[1:-1]
return key, value
def load_env_file(path: Path) -> None:
if not path.exists():
return
for line in path.read_text(encoding="utf-8").splitlines():
parsed = parse_env_line(line)
if parsed is None:
continue
key, value = parsed
os.environ.setdefault(key, value)
def timestamp() -> str: def timestamp() -> str:
@@ -82,10 +127,6 @@ 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 normalize_profile(value: str) -> str:
return value.strip().lower() or "opencode"
def run_macos_action(command: list[str]) -> bool: def run_macos_action(command: list[str]) -> bool:
try: try:
result = subprocess.run(command, check=False, capture_output=True, text=True) result = subprocess.run(command, check=False, capture_output=True, text=True)
@@ -98,7 +139,7 @@ def run_macos_action(command: list[str]) -> bool:
print(f"macOS action failed: {error}", flush=True) print(f"macOS action failed: {error}", flush=True)
return False return False
if env_flag("IPHONE_PHOTO_DEBUG"): if env_flag_value("PHOTO_INBOX_DEBUG"):
output = "\n".join(part for part in [result.stdout.strip(), result.stderr.strip()] if part) output = "\n".join(part for part in [result.stdout.strip(), result.stderr.strip()] if part)
if output: if output:
print(f"macOS action output: {output}", flush=True) print(f"macOS action output: {output}", flush=True)
@@ -115,25 +156,6 @@ 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:
return copy_files_to_clipboard([path])
def copy_file_to_clipboard_with_applescript(path: Path) -> bool:
script = f"""
set theFile to POSIX file {json.dumps(str(path))} as alias
tell application "Finder"
set the clipboard to {{theFile}}
end tell
"""
return run_macos_action(["osascript", "-e", script])
def copy_files_to_clipboard(paths: list[Path]) -> bool: def copy_files_to_clipboard(paths: list[Path]) -> bool:
if not paths: if not paths:
return True return True
@@ -148,133 +170,54 @@ def copy_files_to_clipboard(paths: list[Path]) -> bool:
print("compiled pasteboard helper is older than source; using Swift script", flush=True) print("compiled pasteboard helper is older than source; using Swift script", flush=True)
if helper.exists(): if helper.exists():
if run_macos_action([str(helper), *[str(path) for path in paths]]): return run_macos_action([str(helper), *[str(path) for path in paths]])
return True
print("native pasteboard helper failed; falling back to AppleScript", flush=True)
return copy_files_to_clipboard_with_applescript(paths) print(f"native pasteboard helper missing: {COPY_FILES_HELPER_BIN}", flush=True)
def copy_files_to_clipboard_with_applescript(paths: list[Path]) -> bool:
if not paths:
return True
alias_lines = [
f"set end of theFiles to POSIX file {json.dumps(str(path))} as alias"
for path in paths
]
script = "\n".join(
[
"set theFiles to {}",
*alias_lines,
'tell application "Finder"',
" set the clipboard to theFiles",
"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 paths_reference(paths: list[Path]) -> str:
return "\n".join(str(path) for path in paths)
def terminal_paths_reference(paths: list[Path]) -> str:
return "\n".join(terminal_path_reference(path) for path in paths)
def apply_clipboard_mode(mode: str, paths: list[Path]) -> bool:
if mode == CLIPBOARD_NONE:
return True
if not paths:
return True
latest = paths[-1]
if mode == CLIPBOARD_IMAGE:
return copy_image_to_clipboard(latest)
if mode == CLIPBOARD_PATH:
return copy_text_to_clipboard(paths_reference(paths))
if mode == CLIPBOARD_TERMINAL_PATH:
return copy_text_to_clipboard(terminal_paths_reference(paths))
if mode == CLIPBOARD_FILE:
return copy_file_to_clipboard(latest)
if mode == CLIPBOARD_FILES:
return copy_files_to_clipboard(paths)
print(f"unsupported clipboard mode: {mode}", flush=True)
return False return False
@dataclass
class Batch:
profile: Profile
output_dir: Path
paths: list[Path]
started_at: dt.datetime
updated_at: dt.datetime
timer: threading.Timer | None = None
@property
def count(self) -> int:
return len(self.paths)
class BatchManager: class BatchManager:
def __init__(self, debounce_seconds: float) -> None: def __init__(self, debounce_seconds: float, config: Config) -> None:
self.debounce_seconds = debounce_seconds self.debounce_seconds = debounce_seconds
self.config = config
self.lock = threading.Lock() self.lock = threading.Lock()
self.batches: dict[str, Batch] = {} self.batch: Batch | None = None
def add(self, profile: Profile, output_dir: Path, path: Path) -> tuple[int, list[Path]]: def add(self, path: Path) -> tuple[int, list[Path]]:
with self.lock: with self.lock:
now = dt.datetime.now() now = dt.datetime.now()
batch = self.batches.get(profile.name) if self.batch is None:
if batch is None: self.batch = Batch(self.config.output_dir, [], now, now)
batch = Batch(profile, output_dir, [], now, now)
self.batches[profile.name] = batch
batch.profile = profile self.batch.paths.append(path)
batch.output_dir = output_dir self.batch.updated_at = now
batch.paths.append(path)
batch.updated_at = now
if batch.timer is not None: if self.batch.timer is not None:
batch.timer.cancel() self.batch.timer.cancel()
batch.timer = threading.Timer(self.debounce_seconds, self.finish, args=(profile.name,)) self.batch.timer = threading.Timer(self.debounce_seconds, self.finish)
batch.timer.daemon = True self.batch.timer.daemon = True
batch.timer.start() self.batch.timer.start()
return batch.count, list(batch.paths) return self.batch.count, list(self.batch.paths)
def finish(self, profile_name: str) -> None: def finish(self) -> None:
with self.lock: with self.lock:
batch = self.batches.pop(profile_name, None) batch = self.batch
self.batch = None
if batch is None: if batch is None:
return return
if batch.profile.notify: if self.config.notify:
plural = "photo" if batch.count == 1 else "photos" plural = "photo" if batch.count == 1 else "photos"
message = f"{batch.profile.name}: {batch.count} {plural} ready; clipboard={batch.profile.clipboard}" message = f"{batch.count} {plural} ready; clipboard={'on' if self.config.clipboard else 'off'}"
if notify("iPhone Photo Inbox", message): if notify("Photo Inbox", message):
print(f"batch notification sent profile={batch.profile.name} count={batch.count}", flush=True) print(f"batch notification sent count={batch.count}", flush=True)
print( print(f"batch finalized count={batch.count} dir={batch.output_dir}", flush=True)
f"batch finalized profile={batch.profile.name} count={batch.count} "
f"dir={batch.output_dir}",
flush=True,
)
class UploadHandler(BaseHTTPRequestHandler): class UploadHandler(BaseHTTPRequestHandler):
server_version = "iPhonePhotoInbox/2.0" server_version = "iPhonePhotoInbox/3.0"
def do_GET(self) -> None: def do_GET(self) -> None:
if self.path == "/health": if self.path == "/health":
@@ -295,13 +238,6 @@ class UploadHandler(BaseHTTPRequestHandler):
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")
@@ -322,7 +258,7 @@ 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
output_dir = self.server.output_dir_override or profile.output_dir output_dir = self.server.config.output_dir
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
target = unique_path(output_dir) target = unique_path(output_dir)
with NamedTemporaryFile(dir=output_dir, delete=False) as tmp: with NamedTemporaryFile(dir=output_dir, delete=False) as tmp:
@@ -330,20 +266,17 @@ class UploadHandler(BaseHTTPRequestHandler):
temp_path = Path(tmp.name) temp_path = Path(tmp.name)
temp_path.replace(target) temp_path.replace(target)
if profile.reveal: if self.server.config.reveal:
if reveal_in_finder(target): if reveal_in_finder(target):
print("revealed in Finder", flush=True) print("revealed in Finder", flush=True)
batch_count, batch_paths = self.server.batch_manager.add(profile, output_dir, target) batch_count, batch_paths = self.server.batch_manager.add(target)
if apply_clipboard_mode(profile.clipboard, batch_paths): if self.server.config.clipboard:
print( if copy_files_to_clipboard(batch_paths):
f"clipboard mode applied: {profile.clipboard} " print(f"clipboard updated count={batch_count}", flush=True)
f"profile={profile.name} count={batch_count}",
flush=True,
)
self.send_text(HTTPStatus.CREATED, f"{target}\n") self.send_text(HTTPStatus.CREATED, f"{target}\n")
print(f"saved {target} profile={profile.name} batch_count={batch_count}", flush=True) print(f"saved {target} batch_count={batch_count}", 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)
@@ -362,112 +295,61 @@ class UploadServer(ThreadingHTTPServer):
self, self,
server_address: tuple[str, int], server_address: tuple[str, int],
handler_class: type[BaseHTTPRequestHandler], handler_class: type[BaseHTTPRequestHandler],
profiles: dict[str, Profile], config: Config,
default_profile: str,
output_dir_override: Path | None,
upload_token: str, upload_token: str,
max_bytes: int, max_bytes: int,
debounce_seconds: float, debounce_seconds: float,
) -> None: ) -> None:
super().__init__(server_address, handler_class) super().__init__(server_address, handler_class)
self.profiles = profiles self.config = config
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.batch_manager = BatchManager(debounce_seconds) self.batch_manager = BatchManager(debounce_seconds, config)
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
if env_flag("IPHONE_PHOTO_COPY_FILES"):
clipboard = CLIPBOARD_FILES
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:
env_file = Path(env_str("PHOTO_INBOX_ENV_FILE", str(DEFAULT_ENV_FILE))).expanduser()
load_env_file(env_file)
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=env_str("PHOTO_INBOX_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=env_int("PHOTO_INBOX_PORT", 8787))
parser.add_argument("--profile", default=os.getenv("IPHONE_PHOTO_PROFILE", "opencode")) parser.add_argument("--output-dir", type=Path, default=env_path("PHOTO_INBOX_DIR", DEFAULT_OUTPUT_DIR))
parser.add_argument("--output-dir", type=Path, default=os.getenv("IPHONE_PHOTO_OUTPUT_DIR")) parser.add_argument("--token", default=env_str("PHOTO_INBOX_TOKEN", ""))
parser.add_argument("--token", default=os.getenv("IPHONE_PHOTO_TOKEN", "")) parser.add_argument("--max-mb", type=int, default=env_int("PHOTO_INBOX_MAX_MB", 30))
parser.add_argument("--max-mb", type=int, default=int(os.getenv("IPHONE_PHOTO_MAX_MB", "30"))) parser.add_argument("--debounce-seconds", type=float, default=env_float("PHOTO_INBOX_DEBOUNCE_SECONDS", 5))
parser.add_argument("--debounce-seconds", type=float, default=float(os.getenv("IPHONE_PHOTO_DEBOUNCE_SECONDS", "5"))) parser.add_argument("--clipboard", action=argparse.BooleanOptionalAction, default=env_flag_value("PHOTO_INBOX_CLIPBOARD", True))
parser.add_argument( parser.add_argument("--notify", action=argparse.BooleanOptionalAction, default=env_flag_value("PHOTO_INBOX_NOTIFY", True))
"--clipboard", parser.add_argument("--reveal", action="store_true", default=env_flag_value("PHOTO_INBOX_REVEAL"))
choices=[CLIPBOARD_NONE, CLIPBOARD_IMAGE, CLIPBOARD_PATH, CLIPBOARD_TERMINAL_PATH, CLIPBOARD_FILE, CLIPBOARD_FILES],
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"))
return parser.parse_args() return parser.parse_args()
def build_profiles(args: argparse.Namespace) -> dict[str, Profile]: def build_config(args: argparse.Namespace) -> Config:
profiles = { return Config(
name: apply_legacy_clipboard_overrides(profile) output_dir=args.output_dir.expanduser().resolve(),
for name, profile in profile_defaults().items() clipboard=args.clipboard,
} notify=args.notify,
reveal=args.reveal,
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) config = build_config(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,
profiles, config,
default_profile,
output_dir_override,
args.token, args.token,
args.max_mb * 1024 * 1024, args.max_mb * 1024 * 1024,
args.debounce_seconds, args.debounce_seconds,
) )
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"default profile: {default_profile}", flush=True) print(f"saving to: {config.output_dir}", flush=True)
print(f"debounce seconds: {args.debounce_seconds:g}", flush=True) print(f"debounce seconds: {args.debounce_seconds:g}", flush=True)
for profile in profiles.values(): print(f"clipboard: {config.clipboard}", flush=True)
output_dir = output_dir_override or profile.output_dir print(f"notify: {config.notify}", flush=True)
print( print(f"reveal: {config.reveal}", flush=True)
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: 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()

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HELPER_SRC="$SCRIPT_DIR/copy_files_to_clipboard.swift"
HELPER_BIN="$SCRIPT_DIR/copy_files_to_clipboard"
if command -v swiftc >/dev/null 2>&1; then
if [[ ! -x "$HELPER_BIN" || "$HELPER_SRC" -nt "$HELPER_BIN" ]]; then
swiftc "$HELPER_SRC" -o "$HELPER_BIN"
fi
else
echo "warning: swiftc not found; receiver will run the Swift helper script directly" >&2
fi
exec python3 "$SCRIPT_DIR/receiver.py" "$@"

View File

@@ -20,22 +20,30 @@ SPEC.loader.exec_module(receiver)
class ReceiverTests(unittest.TestCase): class ReceiverTests(unittest.TestCase):
def tearDown(self) -> None: def tearDown(self) -> None:
for batch in getattr(self, "batches", []): batch = getattr(self, "batch", None)
if batch.timer is not None: if batch is not None and batch.timer is not None:
batch.timer.cancel() batch.timer.cancel()
def test_mattermost_profile_defaults_to_files_clipboard(self) -> None: def test_load_env_file_does_not_override_existing_environment(self) -> None:
profile = receiver.profile_defaults()["mattermost"] with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8") as env_file, \
self.assertEqual(profile.clipboard, receiver.CLIPBOARD_FILES) patch.dict(receiver.os.environ, {"PHOTO_INBOX_TOKEN": "existing"}, clear=False):
env_file.write("PHOTO_INBOX_TOKEN=from-file\n")
env_file.write('PHOTO_INBOX_DEBOUNCE_SECONDS="5"\n')
env_file.flush()
def test_batch_add_accumulates_paths_by_profile(self) -> None: receiver.load_env_file(Path(env_file.name))
manager = receiver.BatchManager(debounce_seconds=60)
profile = receiver.Profile("opencode", Path("/tmp"), receiver.CLIPBOARD_TERMINAL_PATH, notify=False)
count1, paths1 = manager.add(profile, Path("/tmp"), Path("/tmp/one.jpg")) self.assertEqual(receiver.os.environ["PHOTO_INBOX_TOKEN"], "existing")
count2, paths2 = manager.add(profile, Path("/tmp"), Path("/tmp/two.jpg")) self.assertEqual(receiver.os.environ["PHOTO_INBOX_DEBOUNCE_SECONDS"], "5")
self.batches = list(manager.batches.values()) def test_batch_add_accumulates_paths(self) -> None:
config = receiver.Config(Path("/tmp"), clipboard=True, notify=False, reveal=False)
manager = receiver.BatchManager(debounce_seconds=60, config=config)
count1, paths1 = manager.add(Path("/tmp/one.jpg"))
count2, paths2 = manager.add(Path("/tmp/two.jpg"))
self.batch = manager.batch
self.assertEqual(count1, 1) self.assertEqual(count1, 1)
self.assertEqual(paths1, [Path("/tmp/one.jpg")]) self.assertEqual(paths1, [Path("/tmp/one.jpg")])
self.assertEqual(count2, 2) self.assertEqual(count2, 2)
@@ -57,13 +65,20 @@ class ReceiverTests(unittest.TestCase):
"/tmp/two.jpg", "/tmp/two.jpg",
]) ])
def test_terminal_paths_clipboard_contains_all_paths(self) -> None: def test_build_config_uses_boolean_clipboard(self) -> None:
paths = [Path("/tmp/one.jpg"), Path("/tmp/two with space.jpg")] args = type("Args", (), {
"output_dir": Path("/tmp/photos"),
"clipboard": False,
"notify": True,
"reveal": False,
})()
with patch.object(receiver, "copy_text_to_clipboard", return_value=True) as copy_text: config = receiver.build_config(args)
self.assertTrue(receiver.apply_clipboard_mode(receiver.CLIPBOARD_TERMINAL_PATH, paths))
copy_text.assert_called_once_with("/tmp/one.jpg\n'/tmp/two with space.jpg'") self.assertEqual(config.output_dir, Path("/tmp/photos").resolve())
self.assertFalse(config.clipboard)
self.assertTrue(config.notify)
self.assertFalse(config.reveal)
if __name__ == "__main__": if __name__ == "__main__":