diff --git a/.github/workflows/screenshots.yml b/.github/workflows/screenshots.yml index b348463..66968c8 100644 --- a/.github/workflows/screenshots.yml +++ b/.github/workflows/screenshots.yml @@ -10,12 +10,18 @@ on: - 'InterclipUITests/**' - 'fastlane/Snapfile' +permissions: + contents: write + jobs: screenshots: runs-on: macos-15 steps: - uses: actions/checkout@v6 + with: + # Need a token that can push back to the branch. + token: ${{ secrets.GITHUB_TOKEN }} - uses: ruby/setup-ruby@v1 with: @@ -71,3 +77,125 @@ jobs: name: screenshots path: fastlane/screenshots retention-days: 30 + + - name: Install frames-cli and imagehash + run: | + pip3 install --break-system-packages Pillow imagehash + curl -fsSL -o /usr/local/bin/frames https://raw.githubusercontent.com/viticci/frames-cli/main/frames + chmod +x /usr/local/bin/frames + frames --version + + - name: Cache Apple Frames asset pack + id: frames-assets-cache + uses: actions/cache@v4 + with: + path: ~/.cache/frames-assets + # Bump when a new AppleFrames pack ships. + key: apple-frames-40 + + - name: Download Apple Frames asset pack + if: steps.frames-assets-cache.outputs.cache-hit != 'true' + run: | + mkdir -p "$HOME/.cache/frames-assets" + curl -fsSL -o /tmp/AppleFrames40.zip https://cdn.macstories.net/AppleFrames40.zip + rm -rf /tmp/frames-extract + mkdir -p /tmp/frames-extract + unzip -q /tmp/AppleFrames40.zip -d /tmp/frames-extract + # The archive has a top-level folder of arbitrary name; locate it via + # NewFrames.json and copy its contents into the cache root. + src=$(find /tmp/frames-extract -name NewFrames.json -exec dirname {} \; | head -1) + if [ -z "$src" ]; then + echo "Could not locate NewFrames.json in AppleFrames40.zip"; exit 1 + fi + cp -R "$src"/. "$HOME/.cache/frames-assets/" + + - name: Frame iPhone 16 Pro screenshots for README + run: | + export FRAMES_ASSETS="$HOME/.cache/frames-assets" + rm -rf docs/screenshots + mkdir -p docs/screenshots + # Strip the device prefix from filenames so framed output is stable + # (e.g. "iPhone 16 Pro-01_Send.png" → "01_Send.png"). + for src in fastlane/screenshots/en-US/iPhone\ 16\ Pro-*.png; do + name="${src##*/iPhone 16 Pro-}" + cp "$src" "docs/screenshots/$name" + done + frames -o docs/screenshots docs/screenshots/*.png + # Keep only the framed PNGs in the repo. + find docs/screenshots -type f -name '*.png' ! -name '*_framed.png' -delete + + - name: Suppress no-op screenshot diffs (perceptual hash gate) + env: + # Hamming distance threshold on a 16x16 pHash (256-bit). 0 = identical, + # ≤5 ≈ visually identical (encoder/anti-aliasing noise only). Bump if + # genuine UI changes are being filtered out. + SCREENSHOT_PHASH_THRESHOLD: '5' + SCREENSHOT_PHASH_SIZE: '16' + run: | + python3 - <<'EOF' + """Replace each freshly framed PNG with its committed counterpart when + the two are perceptually identical. This stops simulator/encoder noise + from producing spurious 'screenshot refresh' commits — only meaningful + UI changes survive the subsequent `git diff` check. + """ + import os, pathlib, subprocess, sys, tempfile + from PIL import Image + import imagehash + + THRESHOLD = int(os.environ['SCREENSHOT_PHASH_THRESHOLD']) + HASH_SIZE = int(os.environ['SCREENSHOT_PHASH_SIZE']) + ROOT = pathlib.Path('docs/screenshots') + + def phash(path): + with Image.open(path) as img: + return imagehash.phash(img, hash_size=HASH_SIZE) + + def committed_bytes(rel_path): + result = subprocess.run( + ['git', 'show', f'HEAD:{rel_path}'], + capture_output=True, + ) + return result.stdout if result.returncode == 0 else None + + new_or_changed = restored = 0 + for current in sorted(ROOT.glob('*_framed.png')): + rel = current.as_posix() + committed = committed_bytes(rel) + if committed is None: + print(f'NEW {rel}') + new_or_changed += 1 + continue + with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp: + tmp.write(committed) + tmp_path = tmp.name + try: + distance = phash(current) - phash(tmp_path) + finally: + os.unlink(tmp_path) + if distance <= THRESHOLD: + print(f'KEEP {rel} (pHash distance {distance} ≤ {THRESHOLD})') + current.write_bytes(committed) + restored += 1 + else: + print(f'CHANGE {rel} (pHash distance {distance} > {THRESHOLD})') + new_or_changed += 1 + + print(f'\nSummary: {new_or_changed} new/changed, {restored} restored to committed bytes.') + EOF + + - name: Commit framed screenshots if changed + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + # -A picks up deletions too, so screenshots removed from the test plan + # also disappear from the repo. + git add -A docs/screenshots + if git diff --cached --quiet; then + echo "No meaningful screenshot changes to commit." + exit 0 + fi + git diff --cached --stat + git commit -m "chore(screenshots): refresh README screenshots" \ + -m "Auto-generated by .github/workflows/screenshots.yml" + git push origin HEAD:${{ github.ref_name }} diff --git a/README.md b/README.md new file mode 100644 index 0000000..fdc1050 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# Interclip for iOS + +The native iOS client for [Interclip](https://interclip.app) — share short links and files between devices. + +Send any URL or file from your iPhone or iPad and get back a short clip code that anyone can paste into Interclip on the web (or another device) to retrieve the original. + +## Features + +- **Send** — Turn any URL into a short clip code. +- **Files** — Upload files and share the resulting clip. +- **Receive** — Look up a clip code to retrieve the original URL or file. +- **Share extension** — Share into Interclip from any other app. +- **App Intents** — Create and look up clips from Shortcuts and Spotlight. + +## Screenshots + +| Send | Files | Receive | Settings | +| :--: | :---: | :-----: | :------: | +| ![Send](docs/screenshots/01_Send_framed.png) | ![Files](docs/screenshots/02_Files_framed.png) | ![Receive](docs/screenshots/03_Receive_framed.png) | ![Settings](docs/screenshots/04_Settings_framed.png) | + +Captured on iPhone 16 Pro, framed with Apple device bezels via [`viticci/frames-cli`](https://github.com/viticci/frames-cli). +Regenerated automatically by the [Screenshots workflow](.github/workflows/screenshots.yml) whenever the UI changes. + +## Requirements + +- iOS 18.0 or later +- Xcode 16 or later +- Ruby 3.3+ with Bundler (for fastlane) + +## Building + +Open the project in Xcode and run the `Interclip` scheme on a simulator or device: + +```sh +open Interclip.xcodeproj +``` + +## Tests + +Unit tests: + +```sh +xcodebuild test \ + -project Interclip.xcodeproj \ + -scheme Interclip \ + -destination 'platform=iOS Simulator,name=iPhone 16 Pro' +``` + +## Regenerating screenshots + +Screenshots are produced by the `InterclipUITests/testScreenshots` UI test and captured with [fastlane snapshot](https://docs.fastlane.tools/actions/snapshot/). Configuration lives in [`fastlane/Snapfile`](fastlane/Snapfile). + +Locally: + +```sh +bundle install +bundle exec fastlane snapshot +``` + +Output is written to `fastlane/screenshots/`. To produce framed versions used in this README, install [`frames-cli`](https://github.com/viticci/frames-cli) and run: + +```sh +frames -o docs/screenshots fastlane/screenshots/en-US/iPhone\ 16\ Pro-*.png +``` + +In CI, the [Screenshots workflow](.github/workflows/screenshots.yml) does both of these on every push to `main` that touches the UI, and commits any visual diffs back to `docs/screenshots/`. +