feat: update iPhone photo inbox scripts with environment file support and refactor batch handling
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
13
scripts/iphone-photo-inbox/.env.example
Normal file
13
scripts/iphone-photo-inbox/.env.example
Normal 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
|
||||||
@@ -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
384
scripts/iphone-photo-inbox/receiver.py
Executable file → Normal 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()
|
||||||
|
|||||||
16
scripts/iphone-photo-inbox/run.sh
Executable file
16
scripts/iphone-photo-inbox/run.sh
Executable 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" "$@"
|
||||||
@@ -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__":
|
||||||
|
|||||||
Reference in New Issue
Block a user