Compare commits
3 Commits
5949820347
...
8950cfcdf0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8950cfcdf0 | |||
| 456a4c3381 | |||
| 7fc4320f46 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -14,6 +14,10 @@ ai/inbox/mattermost-status.json
|
||||
ai/inbox/photos/*
|
||||
!ai/inbox/photos/.gitkeep
|
||||
|
||||
# Local build artifact for the iPhone photo inbox pasteboard helper
|
||||
scripts/iphone-photo-inbox/.env
|
||||
scripts/iphone-photo-inbox/copy_files_to_clipboard
|
||||
|
||||
# Workspace-local Mattermost runtime artifacts
|
||||
scripts/mattermost/.env
|
||||
scripts/mattermost/.venv/
|
||||
|
||||
@@ -31,6 +31,7 @@ tags:
|
||||
- Follow-up SampleApp validation found a likely branch-introduced dismissal regression caused by host-mode mismatch: SampleApp uses `initialViewController(...)` / UIKit presentation, but the branch defaulted manager host mode to SwiftUI, so `endActivity` can take the SwiftUI subject path without a SwiftUI dismissal host subscriber. Fix direction should preserve existing behavior by keeping `initialViewController(...)` on the UIKit dismiss-completion path and `makeInitialFlowView(...)` on the SwiftUI lifecycle path.
|
||||
- SampleApp should support explicit validation of both UIKit-host and SwiftUI-host scenarios. XFlowSDK should remain self-aware of which dismissal mechanism to use based on the active integration path, so SampleApp can exercise both paths without relying on stale singleton/default host-mode state.
|
||||
- May 14 SampleApp iteration: SampleApp was updated to choose between UIKit-host (`initialViewController(...)`) and SwiftUI-host (`makeInitialFlowView(...)`) paths from the existing `Use SwiftUI` setting / feature-toggle path. XFlowSDK entrypoints now prepare the matching dismissal mechanism, and the SampleApp SwiftUI-host rendering was refined to use a `ViewBuilder`/`flowContent` approach rather than storing an `AnyView` in state. Next validation is SampleApp back-to-back host-mode switching plus Fid4 AccountLink revalidation in both host modes.
|
||||
- Jeff recommended expanding validation beyond AccountLink before PR review: test as many current Fid4 XFlow entry and dismissal points as possible in both default SwiftUI-host and forced UIKit-host modes, with particular attention to AO flows. Use the existing Confluence entry-point list as a starting point, but verify it against current code and temporary logs because some entries may be stale.
|
||||
- Keep the separate `HybridBrokerageAccountOpening` / `JointIdentityCheck` scenario out of `PDIAP-15765` scope unless later evidence proves it belongs there
|
||||
- Include feature-flag planning for the broader UIKit-removal spike, including dismissal sequencing changes that affect consumers
|
||||
- Thoroughly verify current `ApexBridgingAddressComponent` / rule-loading usage before describing it as inactive or dead code
|
||||
|
||||
@@ -28,7 +28,7 @@ Update the per-ticket files first when scope, status, sequencing, or communicati
|
||||
|
||||
- `PDIAP-12284` - Remove UIKit wrapping from XFlow
|
||||
Detail: `project-knowledge/02-work-items/pdiap-12284.md`
|
||||
Current note: moved to In Progress on May 12 and should be handled with `PDIAP-15836` in the same branch. Quy confirmed both stories can remain In Progress together, so no immediate Jira restructuring is required. Current implementation direction is to avoid Fid4 per-flow host-mode mapping and instead evaluate XFlowViewMaker-owned global host-mode resolution, with SwiftUI as default and `UIHostingController` only as an explicit temporary fallback. May 12 implementation pass resolved earlier shape concerns with neutral enum names, `iOS-XflowUIKitHostEnabled`, SwiftUI default semantics, and `AnyView` removed from builder internals. Fid4 now compiles after manual dependency/build handling. May 13 AccountLink runtime validation looks good for both SwiftUI-default and forced UIKit-host paths. May 14 SampleApp work added explicit UIKit-host vs SwiftUI-host validation paths and refined SwiftUI-host rendering to avoid local `AnyView` state, using a `ViewBuilder`/`flowContent` path instead. Next validation is SampleApp back-to-back mode switching and Fid4 AccountLink revalidation in both host modes.
|
||||
Current note: moved to In Progress on May 12 and should be handled with `PDIAP-15836` in the same branch. Quy confirmed both stories can remain In Progress together, so no immediate Jira restructuring is required. Current implementation direction is to avoid Fid4 per-flow host-mode mapping and instead evaluate XFlowViewMaker-owned global host-mode resolution, with SwiftUI as default and `UIHostingController` only as an explicit temporary fallback. May 12 implementation pass resolved earlier shape concerns with neutral enum names, `iOS-XflowUIKitHostEnabled`, SwiftUI default semantics, and `AnyView` removed from builder internals. Fid4 now compiles after manual dependency/build handling. May 13 AccountLink runtime validation looks good for both SwiftUI-default and forced UIKit-host paths. May 14 SampleApp work added explicit UIKit-host vs SwiftUI-host validation paths and refined SwiftUI-host rendering to avoid local `AnyView` state, using a `ViewBuilder`/`flowContent` path instead. Next validation is SampleApp back-to-back mode switching and broader Fid4 entry/dismissal validation in both host modes, especially AO flows, per Jeff's review guidance.
|
||||
|
||||
## Backlog / Future Reference
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ tags:
|
||||
- SampleApp should be updated to support explicit validation of both UIKit-host and SwiftUI-host modes. The SDK should choose the appropriate dismissal mechanism from the active integration path, not from stale/default host-mode state.
|
||||
- May 14 SampleApp iteration updated the sample to exercise both host scenarios explicitly: UIKit-host through `initialViewController(...)` and SwiftUI-host through `makeInitialFlowView(...)`, selected by the existing `Use SwiftUI` / feature-toggle path. XFlowSDK entrypoints should prepare their own matching dismissal mechanism so `endActivity` cannot route to the SwiftUI subject unless the SwiftUI host is actually active.
|
||||
- The SampleApp SwiftUI-host rendering was refined to avoid storing `AnyView` in state; SwiftUI-host content now lives behind a `ViewBuilder` / `flowContent` rendering path. This keeps local SampleApp code aligned with the branch goal of avoiding unnecessary `AnyView`, while preserving type erasure only at true public boundaries such as the existing XFlowViewMaker boundary if still required.
|
||||
- Jeff recommended expanding validation before review: test as many current Fid4 XFlow entry and dismissal points as possible in both default SwiftUI-host and forced UIKit-host modes, with particular attention to AO flows. Use the prior Confluence entry-point list as a starting point, but verify it against current code and temporary runtime logs before treating it as complete.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,6 +49,7 @@ tags:
|
||||
- The latest tested run reported the expected dismissal order for the SwiftUI-host path, but the branch still needs review of the shared host-mode routing and any required broader validation before story closure.
|
||||
- Resolved implementation-shape decisions from the May 12 pass: keep two host-mode enum types to preserve the boundary where XFlowViewMaker owns host-selection policy and XFlowSDK owns presentation mechanics, keep the single conversion point in `FlowViewBuilder`, use neutral enum names, and keep SwiftUI as default unless `iOS-XflowUIKitHostEnabled` is explicitly true.
|
||||
- SampleApp-specific rendering should not reintroduce `AnyView` just to switch between validation paths. Prefer view-builder composition plus state that represents the selected host path, plugins, and controller.
|
||||
- Fid4 validation for this branch should go beyond AccountLink: inventory current entry points, confirm which older Confluence-listed entries are still valid, and temporarily log entry path, resolved host mode, dismissal mechanism, delegate callbacks, and session cleanup. Remove those logs before PR publication.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ tags:
|
||||
- May 12 branch validation reported that explicit UIKit host mode still preserves dismissal-completion behavior and delegate callbacks/session cleanup remain after confirmed dismissal. Broader compile validation still depends on resolving the XFlowViewMaker / XFlowSDK dependency mismatch.
|
||||
- May 13 AccountLink runtime validation looks good in both SwiftUI-default and forced UIKit-host modes. Both paths preserved the intended dismissal sequencing, with delegate callbacks and session cleanup after confirmed dismissal.
|
||||
- May 14 SampleApp work refined dismissal validation coverage: SampleApp can exercise both UIKit-host and SwiftUI-host paths, while XFlowSDK entrypoints prepare the matching dismissal mechanism for the active integration path. This supports validating that UIKit dismiss completion and SwiftUI `XFlowDismissalHost` lifecycle sequencing both converge on the canonical delegate/session teardown path.
|
||||
- Jeff recommended broad Fid4 validation before PR review: test as many current XFlow entry and dismissal points as possible in both host modes, especially AO flows, and use temporary logs to confirm the active entry path, host mode, dismissal mechanism, delegate callbacks, and session cleanup.
|
||||
- Quy confirmed on May 13 that this story and `PDIAP-12284` can both remain In Progress together, so no immediate Jira restructuring is required.
|
||||
- Sequenced after `PDIAP-15838` source work, but merge/release is delayed until after REST-transition consumer validation
|
||||
- Sized at `8` points
|
||||
@@ -59,6 +60,7 @@ tags:
|
||||
- Current combined-branch planning should keep dismissal sequencing host-agnostic: the SwiftUI host path must prove dismissal completion before delegate callbacks, while the temporary `UIHostingController` fallback should preserve legacy dismiss-completion behavior.
|
||||
- Current validation logs did not show the key failure patterns for the tested run, such as delegate firing before host-disappearance confirmation or repeated host-disappearance confirmation for the same dismissal id.
|
||||
- SampleApp should be used as a guardrail for stale host-mode state by running UIKit-host and SwiftUI-host flows back-to-back without restarting the app.
|
||||
- Fid4 validation should also use the previous Confluence entry-point list as a starting point, but current source inspection/logging should decide which entries are still valid, renamed, removed, or replaced.
|
||||
- Expect a long-lived branch: after implementation, maintain the branch until consumer-testing approval. Jeff expects the GraphQL-removal branch to merge first after the REST validation period, then that branch should be merged into the `PDIAP-15836` / `PDIAP-12284` branch. Current estimate is roughly 90-100 days from 2026-05-05 unless Fidelity shortens the review windows.
|
||||
|
||||
---
|
||||
|
||||
@@ -30,6 +30,8 @@ tags:
|
||||
- XFlowSDK routing should remain entrypoint-aware: the SwiftUI entrypoint prepares SwiftUI host state and clears UIKit bridge state, while the UIKit entrypoint prepares UIKit host state. This prevents `endActivity` from publishing to the SwiftUI dismissal subject when no `XFlowDismissalHost` subscriber exists.
|
||||
- Follow-up SampleApp changes removed the local `AnyView` state/storage approach and moved SwiftUI-host rendering behind a `ViewBuilder`-style `flowContent` path. This keeps `AnyView` out of SampleApp and leaves type erasure only at boundaries that truly require it, such as the existing XFlowViewMaker public boundary if still needed.
|
||||
- Current SampleApp host-mode validation still appears to depend on a deprecated `enable-swift-ui` toggle. Next source review should align SampleApp flag evaluation with the Fid4-style path for the specific rollback flag that enables the UIKit host, while keeping SwiftUI as the default path.
|
||||
- Jeff recommended broadening Fid4 validation before opening/reviewing the PRs: test as many current Fid4 XFlow entry and dismissal points as possible in both host modes, with particular attention to AO flows.
|
||||
- The next Copilot-assisted validation step should compare the previously created Confluence entry-point list against the current Fid4/XFlow/XFlowViewMaker code, identify stale or still-valid entry points, and add temporary searchable logs to confirm which entry path, host mode, and dismissal path each run uses.
|
||||
|
||||
## Validation To Run
|
||||
|
||||
@@ -38,3 +40,4 @@ tags:
|
||||
- Repeat both modes back-to-back without restarting the app to guard against stale singleton/default host-mode state.
|
||||
- Re-run Fid4 AccountLink validation in default SwiftUI-host and forced UIKit-host modes after the SampleApp changes settle.
|
||||
- After updating SampleApp flag evaluation, validate that the explicit UIKit-host rollback flag selects the UIKit path and that the default/missing/false value selects the SwiftUI path, matching Fid4 behavior as closely as the sample infrastructure allows.
|
||||
- In Fid4, validate a broader entry/dismissal matrix in both host modes, especially AO flows, using temporary logs to confirm entry point, resolved host mode, dismissal mechanism, delegate callbacks, and session cleanup. Remove those logs before PR publication.
|
||||
|
||||
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,39 +1,37 @@
|
||||
# iPhone Photo Inbox
|
||||
# Photo Inbox
|
||||
|
||||
Local HTTP receiver for sending JPEGs from iPhone Shortcuts into Mac inboxes.
|
||||
The Shortcut sends a `profile`, and the Mac decides the destination folder and
|
||||
clipboard behavior.
|
||||
macOS HTTP receiver for sending JPEG uploads into a local photo inbox. Clients
|
||||
can be iPhone Shortcuts, curl, another phone, a script, or any system that can
|
||||
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/`
|
||||
- Copies a terminal-safe path to the clipboard
|
||||
- Best for pasting into OpenCode running in a terminal
|
||||
- Saves photos to `~/Pictures/Photo Inbox` by default.
|
||||
- Groups consecutive uploads into a batch.
|
||||
- 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`
|
||||
|
||||
- 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.
|
||||
This uses a small Swift helper and `NSPasteboard.writeObjects`, which matches
|
||||
Finder-style file clipboard behavior.
|
||||
|
||||
## Start the receiver
|
||||
|
||||
Recommended:
|
||||
|
||||
```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:
|
||||
|
||||
```text
|
||||
@@ -52,7 +50,19 @@ If that does not return an IP, use:
|
||||
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:
|
||||
|
||||
@@ -60,29 +70,24 @@ Use a Dictionary near the top of the Shortcut:
|
||||
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]
|
||||
http://[mac_ip]:[port]/upload?token=[token]
|
||||
```
|
||||
|
||||
Use `profile: opencode` when the next paste target is OpenCode. Use
|
||||
`profile: mattermost` when the next paste target is Mattermost.
|
||||
|
||||
## Camera shortcut
|
||||
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]
|
||||
http://[mac_ip]:[port]/upload?token=[token]
|
||||
|
||||
Take Photo
|
||||
Show Camera Preview: On
|
||||
@@ -94,15 +99,13 @@ Get Contents of URL
|
||||
File: Photo
|
||||
|
||||
Show Notification
|
||||
Sent to [profile]
|
||||
Sent to photo inbox
|
||||
```
|
||||
|
||||
On the tested iPhone flow, `Take Photo` already produces a JPEG, so no
|
||||
conversion step is needed.
|
||||
|
||||
## Existing photos shortcut
|
||||
|
||||
Use this when sending existing images from Photos:
|
||||
Existing Photos Shortcut:
|
||||
|
||||
```text
|
||||
Receive Images and Media from Share Sheet
|
||||
@@ -111,84 +114,98 @@ Repeat with Each Item in Shortcut Input
|
||||
Image: Repeat Item
|
||||
Format: JPEG
|
||||
Get Contents of URL
|
||||
URL: http://[mac_ip]:[port]/upload?token=[token]&profile=[profile]
|
||||
URL: http://[mac_ip]:[port]/upload?token=[token]
|
||||
Method: POST
|
||||
Request Body: File
|
||||
File: Converted Image
|
||||
End Repeat
|
||||
Show Notification
|
||||
Sent to [profile]
|
||||
Sent to photo inbox
|
||||
```
|
||||
|
||||
## Overrides
|
||||
## Configuration
|
||||
|
||||
Profile folders:
|
||||
Common `.env` values:
|
||||
|
||||
```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
|
||||
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
|
||||
```
|
||||
|
||||
Other useful options:
|
||||
|
||||
```bash
|
||||
python3 scripts/iphone-photo-inbox/receiver.py --no-notify
|
||||
python3 scripts/iphone-photo-inbox/receiver.py --reveal
|
||||
scripts/iphone-photo-inbox/run.sh --no-clipboard
|
||||
scripts/iphone-photo-inbox/run.sh --no-notify
|
||||
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
|
||||
|
||||
Startup should print each active profile:
|
||||
Startup should print:
|
||||
|
||||
```text
|
||||
profile opencode: dir=... clipboard=terminal-path notify=True reveal=False
|
||||
profile mattermost: dir=... clipboard=image notify=True reveal=False
|
||||
saving to: ...
|
||||
debounce seconds: 5
|
||||
clipboard: True
|
||||
notify: True
|
||||
```
|
||||
|
||||
After upload, expect:
|
||||
After each upload, expect:
|
||||
|
||||
```text
|
||||
notification sent
|
||||
clipboard mode applied: terminal-path
|
||||
saved ... profile=opencode
|
||||
clipboard updated count=2
|
||||
saved ... batch_count=2
|
||||
```
|
||||
|
||||
For Mattermost, expect:
|
||||
After the debounce window closes, expect:
|
||||
|
||||
```text
|
||||
clipboard mode applied: image
|
||||
batch notification sent count=2
|
||||
batch finalized count=2 dir=...
|
||||
```
|
||||
|
||||
If files arrive but clipboard/notifications do not behave as expected, check:
|
||||
With `PHOTO_INBOX_DEBUG=1`, a two-photo batch should report:
|
||||
|
||||
- The Shortcut URL includes the intended `profile=`.
|
||||
- The receiver log shows the expected profile.
|
||||
- macOS Focus/Do Not Disturb is not hiding notifications.
|
||||
- Terminal/Codex has permission for AppleScript automation if macOS prompts.
|
||||
```text
|
||||
pasteboard files=2 items=2
|
||||
```
|
||||
|
||||
The native file clipboard helper lives at:
|
||||
|
||||
```text
|
||||
scripts/iphone-photo-inbox/copy_files_to_clipboard.swift
|
||||
```
|
||||
|
||||
The compiled binary is ignored by git and generated by `run.sh`:
|
||||
|
||||
```text
|
||||
scripts/iphone-photo-inbox/copy_files_to_clipboard
|
||||
```
|
||||
|
||||
40
scripts/iphone-photo-inbox/copy_files_to_clipboard.swift
Executable file
40
scripts/iphone-photo-inbox/copy_files_to_clipboard.swift
Executable file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env swift
|
||||
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
let paths = CommandLine.arguments.dropFirst()
|
||||
|
||||
guard !paths.isEmpty else {
|
||||
fputs("usage: copy_files_to_clipboard.swift <path> [path...]\n", stderr)
|
||||
exit(64)
|
||||
}
|
||||
|
||||
let urls = paths.map { URL(fileURLWithPath: $0).standardizedFileURL }
|
||||
let missing = urls.filter { !FileManager.default.fileExists(atPath: $0.path) }
|
||||
|
||||
guard missing.isEmpty else {
|
||||
for url in missing {
|
||||
fputs("missing file: \(url.path)\n", stderr)
|
||||
}
|
||||
exit(66)
|
||||
}
|
||||
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
|
||||
let wroteObjects = pasteboard.writeObjects(urls as [NSURL])
|
||||
|
||||
let filenamesType = NSPasteboard.PasteboardType("NSFilenamesPboardType")
|
||||
let pathsList = urls.map(\.path)
|
||||
let wroteFilenames = pasteboard.setPropertyList(pathsList, forType: filenamesType)
|
||||
let wrotePlainText = pasteboard.setString(pathsList.joined(separator: "\n"), forType: .string)
|
||||
|
||||
if wroteObjects || wroteFilenames || wrotePlainText {
|
||||
let itemCount = pasteboard.pasteboardItems?.count ?? 0
|
||||
fputs("pasteboard files=\(urls.count) items=\(itemCount) objects=\(wroteObjects) filenames=\(wroteFilenames) text=\(wrotePlainText)\n", stderr)
|
||||
exit(0)
|
||||
}
|
||||
|
||||
fputs("failed to write file URLs to pasteboard\n", stderr)
|
||||
exit(1)
|
||||
333
scripts/iphone-photo-inbox/receiver.py
Executable file → Normal file
333
scripts/iphone-photo-inbox/receiver.py
Executable file → Normal file
@@ -1,5 +1,5 @@
|
||||
#!/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
|
||||
|
||||
@@ -7,8 +7,8 @@ import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
@@ -17,24 +17,32 @@ from tempfile import NamedTemporaryFile
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
|
||||
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"
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
COPY_FILES_HELPER = SCRIPT_DIR / "copy_files_to_clipboard.swift"
|
||||
COPY_FILES_HELPER_BIN = SCRIPT_DIR / "copy_files_to_clipboard"
|
||||
DEFAULT_ENV_FILE = SCRIPT_DIR / ".env"
|
||||
DEFAULT_OUTPUT_DIR = Path.home() / "Pictures" / "Photo Inbox"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Profile:
|
||||
name: str
|
||||
class Config:
|
||||
output_dir: Path
|
||||
clipboard: str
|
||||
notify: bool = True
|
||||
reveal: bool = False
|
||||
clipboard: bool
|
||||
notify: bool
|
||||
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:
|
||||
@@ -44,20 +52,62 @@ def env_flag(name: str, default: bool = False) -> bool:
|
||||
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:
|
||||
value = os.getenv(name)
|
||||
value = env_value(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 env_str(name: str, default: str) -> str:
|
||||
value = env_value(name)
|
||||
return value if value is not None else default
|
||||
|
||||
|
||||
def env_int(name: str, default: int) -> int:
|
||||
return int(env_str(name, str(default)))
|
||||
|
||||
|
||||
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:
|
||||
@@ -77,10 +127,6 @@ def looks_like_jpeg(data: bytes) -> bool:
|
||||
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:
|
||||
try:
|
||||
result = subprocess.run(command, check=False, capture_output=True, text=True)
|
||||
@@ -93,6 +139,11 @@ def run_macos_action(command: list[str]) -> bool:
|
||||
print(f"macOS action failed: {error}", flush=True)
|
||||
return False
|
||||
|
||||
if env_flag_value("PHOTO_INBOX_DEBUG"):
|
||||
output = "\n".join(part for part in [result.stdout.strip(), result.stderr.strip()] if part)
|
||||
if output:
|
||||
print(f"macOS action output: {output}", flush=True)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -105,47 +156,68 @@ def reveal_in_finder(path: Path) -> bool:
|
||||
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:
|
||||
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_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:
|
||||
def copy_files_to_clipboard(paths: list[Path]) -> bool:
|
||||
if not paths:
|
||||
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)
|
||||
|
||||
helper = COPY_FILES_HELPER
|
||||
if COPY_FILES_HELPER_BIN.exists():
|
||||
source_mtime = COPY_FILES_HELPER.stat().st_mtime if COPY_FILES_HELPER.exists() else 0
|
||||
helper_mtime = COPY_FILES_HELPER_BIN.stat().st_mtime
|
||||
if helper_mtime >= source_mtime:
|
||||
helper = COPY_FILES_HELPER_BIN
|
||||
else:
|
||||
print("compiled pasteboard helper is older than source; using Swift script", flush=True)
|
||||
|
||||
if helper.exists():
|
||||
return run_macos_action([str(helper), *[str(path) for path in paths]])
|
||||
|
||||
print(f"native pasteboard helper missing: {COPY_FILES_HELPER_BIN}", flush=True)
|
||||
return False
|
||||
|
||||
|
||||
class BatchManager:
|
||||
def __init__(self, debounce_seconds: float, config: Config) -> None:
|
||||
self.debounce_seconds = debounce_seconds
|
||||
self.config = config
|
||||
self.lock = threading.Lock()
|
||||
self.batch: Batch | None = None
|
||||
|
||||
def add(self, path: Path) -> tuple[int, list[Path]]:
|
||||
with self.lock:
|
||||
now = dt.datetime.now()
|
||||
if self.batch is None:
|
||||
self.batch = Batch(self.config.output_dir, [], now, now)
|
||||
|
||||
self.batch.paths.append(path)
|
||||
self.batch.updated_at = now
|
||||
|
||||
if self.batch.timer is not None:
|
||||
self.batch.timer.cancel()
|
||||
self.batch.timer = threading.Timer(self.debounce_seconds, self.finish)
|
||||
self.batch.timer.daemon = True
|
||||
self.batch.timer.start()
|
||||
|
||||
return self.batch.count, list(self.batch.paths)
|
||||
|
||||
def finish(self) -> None:
|
||||
with self.lock:
|
||||
batch = self.batch
|
||||
self.batch = None
|
||||
if batch is None:
|
||||
return
|
||||
|
||||
if self.config.notify:
|
||||
plural = "photo" if batch.count == 1 else "photos"
|
||||
message = f"{batch.count} {plural} ready; clipboard={'on' if self.config.clipboard else 'off'}"
|
||||
if notify("Photo Inbox", message):
|
||||
print(f"batch notification sent count={batch.count}", flush=True)
|
||||
|
||||
print(f"batch finalized count={batch.count} dir={batch.output_dir}", flush=True)
|
||||
|
||||
|
||||
class UploadHandler(BaseHTTPRequestHandler):
|
||||
server_version = "iPhonePhotoInbox/2.0"
|
||||
server_version = "iPhonePhotoInbox/3.0"
|
||||
|
||||
def do_GET(self) -> None:
|
||||
if self.path == "/health":
|
||||
@@ -166,13 +238,6 @@ class UploadHandler(BaseHTTPRequestHandler):
|
||||
self.send_text(HTTPStatus.UNAUTHORIZED, "bad token\n")
|
||||
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")
|
||||
if content_length is None:
|
||||
self.send_text(HTTPStatus.LENGTH_REQUIRED, "missing content length\n")
|
||||
@@ -193,7 +258,7 @@ class UploadHandler(BaseHTTPRequestHandler):
|
||||
self.send_text(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "expected jpeg\n")
|
||||
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)
|
||||
target = unique_path(output_dir)
|
||||
with NamedTemporaryFile(dir=output_dir, delete=False) as tmp:
|
||||
@@ -201,17 +266,17 @@ class UploadHandler(BaseHTTPRequestHandler):
|
||||
temp_path = Path(tmp.name)
|
||||
temp_path.replace(target)
|
||||
|
||||
if profile.notify:
|
||||
if notify("iPhone Photo Inbox", f"{profile.name}: {target.name}"):
|
||||
print("notification sent", flush=True)
|
||||
if profile.reveal:
|
||||
if self.server.config.reveal:
|
||||
if reveal_in_finder(target):
|
||||
print("revealed in Finder", flush=True)
|
||||
if apply_clipboard_mode(profile.clipboard, target):
|
||||
print(f"clipboard mode applied: {profile.clipboard}", flush=True)
|
||||
|
||||
batch_count, batch_paths = self.server.batch_manager.add(target)
|
||||
if self.server.config.clipboard:
|
||||
if copy_files_to_clipboard(batch_paths):
|
||||
print(f"clipboard updated count={batch_count}", flush=True)
|
||||
|
||||
self.send_text(HTTPStatus.CREATED, f"{target}\n")
|
||||
print(f"saved {target} profile={profile.name}", flush=True)
|
||||
print(f"saved {target} batch_count={batch_count}", flush=True)
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
print(f"{self.address_string()} - {format % args}", flush=True)
|
||||
@@ -230,105 +295,61 @@ class UploadServer(ThreadingHTTPServer):
|
||||
self,
|
||||
server_address: tuple[str, int],
|
||||
handler_class: type[BaseHTTPRequestHandler],
|
||||
profiles: dict[str, Profile],
|
||||
default_profile: str,
|
||||
output_dir_override: Path | None,
|
||||
config: Config,
|
||||
upload_token: str,
|
||||
max_bytes: int,
|
||||
debounce_seconds: float,
|
||||
) -> None:
|
||||
super().__init__(server_address, handler_class)
|
||||
self.profiles = profiles
|
||||
self.default_profile = default_profile
|
||||
self.output_dir_override = output_dir_override
|
||||
self.config = config
|
||||
self.upload_token = upload_token
|
||||
self.max_bytes = max_bytes
|
||||
|
||||
|
||||
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)
|
||||
self.batch_manager = BatchManager(debounce_seconds, config)
|
||||
|
||||
|
||||
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.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("--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("--max-mb", type=int, default=int(os.getenv("IPHONE_PHOTO_MAX_MB", "30")))
|
||||
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("--host", default=env_str("PHOTO_INBOX_HOST", "0.0.0.0"))
|
||||
parser.add_argument("--port", type=int, default=env_int("PHOTO_INBOX_PORT", 8787))
|
||||
parser.add_argument("--output-dir", type=Path, default=env_path("PHOTO_INBOX_DIR", DEFAULT_OUTPUT_DIR))
|
||||
parser.add_argument("--token", default=env_str("PHOTO_INBOX_TOKEN", ""))
|
||||
parser.add_argument("--max-mb", type=int, default=env_int("PHOTO_INBOX_MAX_MB", 30))
|
||||
parser.add_argument("--debounce-seconds", type=float, default=env_float("PHOTO_INBOX_DEBOUNCE_SECONDS", 5))
|
||||
parser.add_argument("--clipboard", action=argparse.BooleanOptionalAction, default=env_flag_value("PHOTO_INBOX_CLIPBOARD", True))
|
||||
parser.add_argument("--notify", action=argparse.BooleanOptionalAction, default=env_flag_value("PHOTO_INBOX_NOTIFY", True))
|
||||
parser.add_argument("--reveal", action="store_true", default=env_flag_value("PHOTO_INBOX_REVEAL"))
|
||||
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 build_config(args: argparse.Namespace) -> Config:
|
||||
return Config(
|
||||
output_dir=args.output_dir.expanduser().resolve(),
|
||||
clipboard=args.clipboard,
|
||||
notify=args.notify,
|
||||
reveal=args.reveal,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
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
|
||||
config = build_config(args)
|
||||
server = UploadServer(
|
||||
(args.host, args.port),
|
||||
UploadHandler,
|
||||
profiles,
|
||||
default_profile,
|
||||
output_dir_override,
|
||||
config,
|
||||
args.token,
|
||||
args.max_mb * 1024 * 1024,
|
||||
args.debounce_seconds,
|
||||
)
|
||||
print(f"listening on http://{args.host}:{args.port}/upload", flush=True)
|
||||
print(f"default profile: {default_profile}", flush=True)
|
||||
for profile in profiles.values():
|
||||
output_dir = output_dir_override or profile.output_dir
|
||||
print(
|
||||
f"profile {profile.name}: dir={output_dir.expanduser().resolve()} "
|
||||
f"clipboard={profile.clipboard} notify={profile.notify} reveal={profile.reveal}",
|
||||
flush=True,
|
||||
)
|
||||
print(f"saving to: {config.output_dir}", flush=True)
|
||||
print(f"debounce seconds: {args.debounce_seconds:g}", flush=True)
|
||||
print(f"clipboard: {config.clipboard}", flush=True)
|
||||
print(f"notify: {config.notify}", flush=True)
|
||||
print(f"reveal: {config.reveal}", flush=True)
|
||||
if not args.token:
|
||||
print("warning: no token configured; anyone on this network can upload", flush=True)
|
||||
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" "$@"
|
||||
85
scripts/iphone-photo-inbox/test_receiver.py
Normal file
85
scripts/iphone-photo-inbox/test_receiver.py
Normal file
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
RECEIVER_PATH = Path(__file__).with_name("receiver.py")
|
||||
SPEC = importlib.util.spec_from_file_location("iphone_photo_receiver", RECEIVER_PATH)
|
||||
receiver = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader is not None
|
||||
sys.modules[SPEC.name] = receiver
|
||||
SPEC.loader.exec_module(receiver)
|
||||
|
||||
|
||||
class ReceiverTests(unittest.TestCase):
|
||||
def tearDown(self) -> None:
|
||||
batch = getattr(self, "batch", None)
|
||||
if batch is not None and batch.timer is not None:
|
||||
batch.timer.cancel()
|
||||
|
||||
def test_load_env_file_does_not_override_existing_environment(self) -> None:
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8") as env_file, \
|
||||
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()
|
||||
|
||||
receiver.load_env_file(Path(env_file.name))
|
||||
|
||||
self.assertEqual(receiver.os.environ["PHOTO_INBOX_TOKEN"], "existing")
|
||||
self.assertEqual(receiver.os.environ["PHOTO_INBOX_DEBOUNCE_SECONDS"], "5")
|
||||
|
||||
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(paths1, [Path("/tmp/one.jpg")])
|
||||
self.assertEqual(count2, 2)
|
||||
self.assertEqual(paths2, [Path("/tmp/one.jpg"), Path("/tmp/two.jpg")])
|
||||
|
||||
def test_files_clipboard_passes_all_paths_to_native_helper(self) -> None:
|
||||
paths = [Path("/tmp/one.jpg"), Path("/tmp/two.jpg")]
|
||||
|
||||
with tempfile.NamedTemporaryFile() as helper:
|
||||
helper_path = Path(helper.name)
|
||||
with patch.object(receiver, "COPY_FILES_HELPER_BIN", Path("/tmp/missing-helper-bin")), \
|
||||
patch.object(receiver, "COPY_FILES_HELPER", helper_path), \
|
||||
patch.object(receiver, "run_macos_action", return_value=True) as run_action:
|
||||
self.assertTrue(receiver.copy_files_to_clipboard(paths))
|
||||
|
||||
run_action.assert_called_once_with([
|
||||
str(helper_path),
|
||||
"/tmp/one.jpg",
|
||||
"/tmp/two.jpg",
|
||||
])
|
||||
|
||||
def test_build_config_uses_boolean_clipboard(self) -> None:
|
||||
args = type("Args", (), {
|
||||
"output_dir": Path("/tmp/photos"),
|
||||
"clipboard": False,
|
||||
"notify": True,
|
||||
"reveal": False,
|
||||
})()
|
||||
|
||||
config = receiver.build_config(args)
|
||||
|
||||
self.assertEqual(config.output_dir, Path("/tmp/photos").resolve())
|
||||
self.assertFalse(config.clipboard)
|
||||
self.assertTrue(config.notify)
|
||||
self.assertFalse(config.reveal)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user