Compare commits

..

3 Commits

12 changed files with 446 additions and 242 deletions

4
.gitignore vendored
View File

@@ -14,6 +14,10 @@ ai/inbox/mattermost-status.json
ai/inbox/photos/* ai/inbox/photos/*
!ai/inbox/photos/.gitkeep !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 # Workspace-local Mattermost runtime artifacts
scripts/mattermost/.env scripts/mattermost/.env
scripts/mattermost/.venv/ scripts/mattermost/.venv/

View File

@@ -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. - 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. - 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. - 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 - 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 - 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 - Thoroughly verify current `ApexBridgingAddressComponent` / rule-loading usage before describing it as inactive or dead code

View File

@@ -28,7 +28,7 @@ Update the per-ticket files first when scope, status, sequencing, or communicati
- `PDIAP-12284` - Remove UIKit wrapping from XFlow - `PDIAP-12284` - Remove UIKit wrapping from XFlow
Detail: `project-knowledge/02-work-items/pdiap-12284.md` 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 ## Backlog / Future Reference

View File

@@ -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. - 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. - 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. - 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. - 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. - 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. - 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.
--- ---

View File

@@ -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 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 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. - 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. - 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 - Sequenced after `PDIAP-15838` source work, but merge/release is delayed until after REST-transition consumer validation
- Sized at `8` points - 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 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. - 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. - 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. - 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.
--- ---

View File

@@ -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. - 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. - 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. - 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 ## 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. - 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. - 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. - 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.

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,39 +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 a terminal-safe path 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 the image data to the clipboard
- Best for pasting directly into Mattermost
`general`
- Saves to `~/Pictures/iPhone Inbox`
- Does not modify the clipboard
- Useful for plain capture
All profiles show a macOS notification by default.
## Start the receiver ## Start the receiver
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
@@ -52,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:
@@ -60,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
@@ -94,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
@@ -111,84 +114,98 @@ 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=terminal-path
IPHONE_PHOTO_CLIPBOARD=path
IPHONE_PHOTO_CLIPBOARD=file
IPHONE_PHOTO_CLIPBOARD=none
``` ```
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
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=image notify=True reveal=False debounce seconds: 5
clipboard: True
notify: True
``` ```
After upload, expect: After each upload, expect:
```text ```text
notification sent clipboard updated count=2
clipboard mode applied: terminal-path saved ... batch_count=2
saved ... profile=opencode
``` ```
For Mattermost, expect: After the debounce window closes, expect:
```text ```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=`. ```text
- The receiver log shows the expected profile. pasteboard files=2 items=2
- macOS Focus/Do Not Disturb is not hiding notifications. ```
- Terminal/Codex has permission for AppleScript automation if macOS prompts.
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
```

View 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
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,8 +7,8 @@ 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
from dataclasses import dataclass from dataclasses import dataclass
from http import HTTPStatus from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -17,24 +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
DEFAULT_OPENCODE_DIR = WORKSPACE_DIR / "ai" / "inbox" / "photos" COPY_FILES_HELPER = SCRIPT_DIR / "copy_files_to_clipboard.swift"
DEFAULT_MATTERMOST_DIR = Path.home() / "Pictures" / "iPhone Inbox" COPY_FILES_HELPER_BIN = SCRIPT_DIR / "copy_files_to_clipboard"
DEFAULT_ENV_FILE = SCRIPT_DIR / ".env"
CLIPBOARD_NONE = "none" DEFAULT_OUTPUT_DIR = Path.home() / "Pictures" / "Photo Inbox"
CLIPBOARD_IMAGE = "image"
CLIPBOARD_PATH = "path"
CLIPBOARD_TERMINAL_PATH = "terminal-path"
CLIPBOARD_FILE = "file"
@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:
@@ -44,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_IMAGE), 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:
@@ -77,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)
@@ -93,6 +139,11 @@ 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_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 return True
@@ -105,47 +156,68 @@ 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: def copy_files_to_clipboard(paths: list[Path]) -> bool:
script = f"set the clipboard to (read (POSIX file {json.dumps(str(path))}) as JPEG picture)" if not paths:
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:
return True return True
if mode == CLIPBOARD_IMAGE:
return copy_image_to_clipboard(path) helper = COPY_FILES_HELPER
if mode == CLIPBOARD_PATH: if COPY_FILES_HELPER_BIN.exists():
return copy_text_to_clipboard(str(path)) source_mtime = COPY_FILES_HELPER.stat().st_mtime if COPY_FILES_HELPER.exists() else 0
if mode == CLIPBOARD_TERMINAL_PATH: helper_mtime = COPY_FILES_HELPER_BIN.stat().st_mtime
return copy_text_to_clipboard(terminal_path_reference(path)) if helper_mtime >= source_mtime:
if mode == CLIPBOARD_FILE: helper = COPY_FILES_HELPER_BIN
return copy_file_to_clipboard(path) else:
print(f"unsupported clipboard mode: {mode}", flush=True) 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 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): 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":
@@ -166,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")
@@ -193,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:
@@ -201,17 +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.notify: if self.server.config.reveal:
if notify("iPhone Photo Inbox", f"{profile.name}: {target.name}"):
print("notification sent", flush=True)
if profile.reveal:
if reveal_in_finder(target): if reveal_in_finder(target):
print("revealed in Finder", flush=True) print("revealed in Finder", flush=True)
if 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") 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: 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)
@@ -230,105 +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,
) -> 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, 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
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( parser.add_argument("--clipboard", action=argparse.BooleanOptionalAction, default=env_flag_value("PHOTO_INBOX_CLIPBOARD", True))
"--clipboard", parser.add_argument("--notify", action=argparse.BooleanOptionalAction, default=env_flag_value("PHOTO_INBOX_NOTIFY", True))
choices=[CLIPBOARD_NONE, CLIPBOARD_IMAGE, CLIPBOARD_PATH, CLIPBOARD_TERMINAL_PATH, CLIPBOARD_FILE], parser.add_argument("--reveal", action="store_true", default=env_flag_value("PHOTO_INBOX_REVEAL"))
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,
) )
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)
for profile in profiles.values(): print(f"debounce seconds: {args.debounce_seconds:g}", flush=True)
output_dir = output_dir_override or profile.output_dir print(f"clipboard: {config.clipboard}", flush=True)
print( print(f"notify: {config.notify}", flush=True)
f"profile {profile.name}: dir={output_dir.expanduser().resolve()} " print(f"reveal: {config.reveal}", flush=True)
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

@@ -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()