mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-06 18:59:57 +02:00
Compare commits
48 Commits
e22e395efc
...
feat/relea
| Author | SHA1 | Date | |
|---|---|---|---|
| 4730554c2c | |||
| cbb535d998 | |||
| a0ebd7c71b | |||
| cc194f6a7b | |||
| 8de190a36e | |||
| 73bc87d3d1 | |||
| 2166aa9ce4 | |||
| 22b93ce94e | |||
| f3124371ee | |||
| ea2f8b1cc7 | |||
| 9b8d66f97d | |||
| a8a168ffde | |||
| 516c4ace84 | |||
|
|
c7657c4b37 | ||
| b8e8dd8644 | |||
| 83b66c5eb7 | |||
| 81e84b14f8 | |||
| c81cb7c8b6 | |||
| 319af6f2de | |||
| 3abd4d0cfd | |||
| b66cb8c1f1 | |||
| 98da43b122 | |||
| 7d3f1b9862 | |||
| 83ddf9f059 | |||
| f513a6118e | |||
| 35f06a0b6b | |||
| b65bac021f | |||
| d2924f7ce6 | |||
| 3042005226 | |||
| 9b15a388d8 | |||
| c23f7916bb | |||
|
|
0f8f89641a | ||
| b724c1540f | |||
| 1d049d08f2 | |||
| aab5f243ca | |||
| 065d57e896 | |||
| c7ebaee15b | |||
| 425500ecf2 | |||
| 30dfabf8e7 | |||
| 4bd51106e8 | |||
| 6e2c8b4b2d | |||
| 0448137d84 | |||
| 4074f4bee8 | |||
| 7cc0e825f6 | |||
| efb3c474d1 | |||
| 7592d49a59 | |||
| a0bcc5dbff | |||
| cbace73908 |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.af filter=lfs diff=lfs merge=lfs -text
|
||||||
230
.github/workflows/apple-release.yml
vendored
Normal file
230
.github/workflows/apple-release.yml
vendored
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
name: Apple release (macOS DMG)
|
||||||
|
|
||||||
|
# Builds, signs, notarizes, and publishes the direct-download macOS build:
|
||||||
|
# - a Developer ID–signed, notarized VniDrop-<version>.dmg,
|
||||||
|
# - a Sparkle appcast.xml (both attached to the GitHub Release), and
|
||||||
|
# - an updated Homebrew cask pushed to the sudosylabs/homebrew-vnidrop tap.
|
||||||
|
#
|
||||||
|
# The App Store / TestFlight build is NOT produced here — that goes through Xcode
|
||||||
|
# Organizer / App Store Connect. This workflow only covers direct distribution.
|
||||||
|
#
|
||||||
|
# Trigger: push a tag vMAJOR.MINOR.PATCH (must point at a commit on master), or
|
||||||
|
# run manually with an explicit version (produces artifacts, no Release).
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*.*.*"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: Release version in MAJOR.MINOR.PATCH form
|
||||||
|
required: true
|
||||||
|
default: "0.1.0"
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: apple-release-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build & notarize DMG
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 90
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.version.outputs.app }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Verify tag is on master
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
run: |
|
||||||
|
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/master; then
|
||||||
|
echo "Release tags must point to a commit on master" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Resolve version
|
||||||
|
id: version
|
||||||
|
env:
|
||||||
|
REQUESTED_VERSION: ${{ inputs.version || '' }}
|
||||||
|
run: |
|
||||||
|
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
|
||||||
|
version="${GITHUB_REF_NAME#v}"
|
||||||
|
else
|
||||||
|
version="$REQUESTED_VERSION"
|
||||||
|
fi
|
||||||
|
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "bad version '$version'" >&2; exit 1; }
|
||||||
|
echo "app=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Select Xcode
|
||||||
|
run: sudo xcode-select -s /Applications/Xcode.app
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1
|
||||||
|
with:
|
||||||
|
toolchain: stable
|
||||||
|
targets: aarch64-apple-darwin
|
||||||
|
|
||||||
|
- name: Cache Cargo
|
||||||
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: apple-release-cargo-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: apple-release-cargo-
|
||||||
|
|
||||||
|
- name: Install tooling
|
||||||
|
run: brew install xcodegen swiftlint create-dmg
|
||||||
|
|
||||||
|
- name: Install Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Download Sparkle tools
|
||||||
|
# generate_appcast + sign_update ship in the Sparkle release tarball.
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
ver="2.9.4"
|
||||||
|
curl -fsSL -o /tmp/sparkle.tar.xz \
|
||||||
|
"https://github.com/sparkle-project/Sparkle/releases/download/${ver}/Sparkle-${ver}.tar.xz"
|
||||||
|
mkdir -p /tmp/sparkle && tar -xJf /tmp/sparkle.tar.xz -C /tmp/sparkle
|
||||||
|
echo "SPARKLE_BIN=/tmp/sparkle/bin" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Import Developer ID certificate
|
||||||
|
env:
|
||||||
|
CERT_P12_BASE64: ${{ secrets.DEVELOPER_ID_CERT_P12 }}
|
||||||
|
CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
keychain="$RUNNER_TEMP/signing.keychain-db"
|
||||||
|
kpw="$(openssl rand -hex 20)"
|
||||||
|
security create-keychain -p "$kpw" "$keychain"
|
||||||
|
security set-keychain-settings -lut 21600 "$keychain"
|
||||||
|
security unlock-keychain -p "$kpw" "$keychain"
|
||||||
|
echo "$CERT_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
|
||||||
|
security import "$RUNNER_TEMP/cert.p12" -k "$keychain" -P "$CERT_PASSWORD" \
|
||||||
|
-T /usr/bin/codesign -T /usr/bin/security
|
||||||
|
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$kpw" "$keychain"
|
||||||
|
# Prepend our keychain so codesign/xcodebuild can find the identity.
|
||||||
|
security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"')
|
||||||
|
rm -f "$RUNNER_TEMP/cert.p12"
|
||||||
|
|
||||||
|
- name: Store notarytool credentials
|
||||||
|
env:
|
||||||
|
NOTARY_KEY_P8: ${{ secrets.NOTARY_API_KEY }}
|
||||||
|
NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }}
|
||||||
|
NOTARY_ISSUER: ${{ secrets.NOTARY_ISSUER }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "$NOTARY_KEY_P8" | base64 --decode > "$RUNNER_TEMP/notary.p8"
|
||||||
|
xcrun notarytool store-credentials vnidrop-notary \
|
||||||
|
--key "$RUNNER_TEMP/notary.p8" \
|
||||||
|
--key-id "$NOTARY_KEY_ID" \
|
||||||
|
--issuer "$NOTARY_ISSUER"
|
||||||
|
echo "NOTARY_PROFILE=vnidrop-notary" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Write Sparkle signing key
|
||||||
|
env:
|
||||||
|
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
printf '%s' "$SPARKLE_ED_PRIVATE_KEY" > "$RUNNER_TEMP/sparkle_ed_private_key"
|
||||||
|
echo "SPARKLE_ED_KEY_FILE=$RUNNER_TEMP/sparkle_ed_private_key" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Build, sign & notarize DMG
|
||||||
|
run: apple/scripts/build-dmg.sh "${{ steps.version.outputs.app }}"
|
||||||
|
|
||||||
|
- name: Generate appcast
|
||||||
|
env:
|
||||||
|
RELEASE_REPO: ${{ github.repository }}
|
||||||
|
run: apple/scripts/generate-appcast.sh "${{ steps.version.outputs.app }}"
|
||||||
|
|
||||||
|
- name: Upload artifacts
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: vnidrop-${{ steps.version.outputs.app }}-macos-dmg
|
||||||
|
path: |
|
||||||
|
apple/dist/VniDrop-*.dmg
|
||||||
|
apple/dist/appcast.xml
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
- name: Publish GitHub Release
|
||||||
|
if: github.event_name == 'push' && github.ref_type == 'tag'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
GH_REPO: ${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
tag="$GITHUB_REF_NAME"
|
||||||
|
version="${tag#v}"
|
||||||
|
if gh release view "$tag" >/dev/null 2>&1; then
|
||||||
|
echo "Release $tag already exists; refusing to replace assets" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh release create "$tag" \
|
||||||
|
"apple/dist/VniDrop-${version}.dmg" \
|
||||||
|
"apple/dist/appcast.xml" \
|
||||||
|
--verify-tag \
|
||||||
|
--title "VniDrop $version" \
|
||||||
|
--generate-notes
|
||||||
|
|
||||||
|
update-cask:
|
||||||
|
name: Update Homebrew cask
|
||||||
|
needs: build
|
||||||
|
if: github.event_name == 'push' && github.ref_type == 'tag'
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Download DMG artifact
|
||||||
|
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||||
|
with:
|
||||||
|
name: vnidrop-${{ needs.build.outputs.version }}-macos-dmg
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- name: Render cask
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.build.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
sha="$(sha256sum "dist/VniDrop-${VERSION}.dmg" | cut -d' ' -f1)"
|
||||||
|
sed -e "s/^ version \".*\"/ version \"${VERSION}\"/" \
|
||||||
|
-e "s/^ sha256 \".*\"/ sha256 \"${sha}\"/" \
|
||||||
|
packaging/homebrew/vnidrop.rb > /tmp/vnidrop.rb
|
||||||
|
echo "Rendered cask:"; cat /tmp/vnidrop.rb
|
||||||
|
|
||||||
|
- name: Push to tap
|
||||||
|
env:
|
||||||
|
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||||
|
VERSION: ${{ needs.build.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git clone "https://x-access-token:${TAP_TOKEN}@github.com/sudosylabs/homebrew-vnidrop.git" tap
|
||||||
|
mkdir -p tap/Casks
|
||||||
|
cp /tmp/vnidrop.rb tap/Casks/vnidrop.rb
|
||||||
|
cd tap
|
||||||
|
git config user.name "vnidrop-release-bot"
|
||||||
|
git config user.email "release-bot@users.noreply.github.com"
|
||||||
|
git add Casks/vnidrop.rb
|
||||||
|
git commit -m "vnidrop ${VERSION}" || { echo "no cask changes"; exit 0; }
|
||||||
|
git push
|
||||||
9
.github/workflows/apple.yml
vendored
9
.github/workflows/apple.yml
vendored
@@ -65,6 +65,10 @@ jobs:
|
|||||||
- name: Install XcodeGen
|
- name: Install XcodeGen
|
||||||
run: brew install xcodegen
|
run: brew install xcodegen
|
||||||
|
|
||||||
|
- name: Install SwiftLint
|
||||||
|
# Required by the VniDrop target's SwiftLint build phase (typed-resources rules).
|
||||||
|
run: brew install swiftlint
|
||||||
|
|
||||||
- name: Install Bun
|
- name: Install Bun
|
||||||
# The Apple l10n catalog (Localizable.xcstrings) and L10n.swift are
|
# The Apple l10n catalog (Localizable.xcstrings) and L10n.swift are
|
||||||
# generated from localization/strings.json at build time, not tracked.
|
# generated from localization/strings.json at build time, not tracked.
|
||||||
@@ -72,3 +76,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and test Apple app
|
- name: Build and test Apple app
|
||||||
run: make check-apple
|
run: make check-apple
|
||||||
|
|
||||||
|
- name: Build direct-download macOS target (Sparkle, unsigned)
|
||||||
|
# Keeps the VniDropDirect (.dmg/Sparkle) target compiling; signing and
|
||||||
|
# notarization happen only in apple-release.yml on a tag.
|
||||||
|
run: make build-apple-macos-direct
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -24,3 +24,4 @@ config.override.mk
|
|||||||
# Local design export scratch
|
# Local design export scratch
|
||||||
output/
|
output/
|
||||||
.screenshots
|
.screenshots
|
||||||
|
apple/RELEASE-MACOS.md
|
||||||
|
|||||||
11
AGENTS.md
11
AGENTS.md
@@ -48,6 +48,15 @@ Domain docs (reference, do not paste into PRs):
|
|||||||
8. **Every bug fix includes a regression test** at the lowest layer that catches it.
|
8. **Every bug fix includes a regression test** at the lowest layer that catches it.
|
||||||
9. After code changes, run the **relevant** checks in [Build and test](#build-and-test)
|
9. After code changes, run the **relevant** checks in [Build and test](#build-and-test)
|
||||||
and fix failures before finishing.
|
and fix failures before finishing.
|
||||||
|
10. **`localization/strings.json` is the single source of truth for all localized
|
||||||
|
strings.** The KMP Compose resources (`shared/src/commonMain/composeResources/
|
||||||
|
values*/strings.xml`) and the Apple catalog + accessors
|
||||||
|
(`apple/VniDrop/Resources/Localizable.xcstrings`, `apple/VniDrop/Generated/
|
||||||
|
L10n.swift`) are **generated** by the loc CLI (`cd localization && bun run
|
||||||
|
src/cli.ts generate`) — never hand-edit them. To add/change a string: edit
|
||||||
|
`strings.json` (set `targets` to `kmp`, `apple`, or omit for both), then
|
||||||
|
regenerate. A key referenced in code but only present in a generated file will
|
||||||
|
be silently dropped the next time generation runs.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -291,6 +300,8 @@ branch from updated `master`.
|
|||||||
- Flaky multi-minute sleeps in tests
|
- Flaky multi-minute sleeps in tests
|
||||||
- Unsigned commits when signing is required
|
- Unsigned commits when signing is required
|
||||||
- Force-push or secret commits without explicit user direction
|
- Force-push or secret commits without explicit user direction
|
||||||
|
- Hand-editing generated localization files (`values*/strings.xml`,
|
||||||
|
`Localizable.xcstrings`, `L10n.swift`) instead of `localization/strings.json`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
389
Cargo.lock
generated
389
Cargo.lock
generated
@@ -61,12 +61,56 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstream"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"anstyle-parse",
|
||||||
|
"anstyle-query",
|
||||||
|
"anstyle-wincon",
|
||||||
|
"colorchoice",
|
||||||
|
"is_terminal_polyfill",
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anstyle"
|
name = "anstyle"
|
||||||
version = "1.0.14"
|
version = "1.0.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-parse"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||||
|
dependencies = [
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-query"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-wincon"
|
||||||
|
version = "3.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"once_cell_polyfill",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.103"
|
version = "1.0.103"
|
||||||
@@ -463,9 +507,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg_aliases"
|
name = "cfg_aliases"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chacha20"
|
name = "chacha20"
|
||||||
@@ -518,6 +562,7 @@ version = "4.6.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
|
checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"clap_lex",
|
"clap_lex",
|
||||||
"strsim",
|
"strsim",
|
||||||
@@ -556,6 +601,12 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -777,38 +828,17 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "dashmap"
|
||||||
version = "0.20.11"
|
version = "6.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
|
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling_core",
|
"cfg-if",
|
||||||
"darling_macro",
|
"crossbeam-utils",
|
||||||
]
|
"hashbrown 0.14.5",
|
||||||
|
"lock_api",
|
||||||
[[package]]
|
"once_cell",
|
||||||
name = "darling_core"
|
"parking_lot_core",
|
||||||
version = "0.20.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
|
|
||||||
dependencies = [
|
|
||||||
"fnv",
|
|
||||||
"ident_case",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"strsim",
|
|
||||||
"syn 2.0.118",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "darling_macro"
|
|
||||||
version = "0.20.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
|
||||||
dependencies = [
|
|
||||||
"darling_core",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.118",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -834,7 +864,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
|
checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
"syn 2.0.118",
|
"syn 1.0.109",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -879,37 +909,6 @@ version = "0.5.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "derive_builder"
|
|
||||||
version = "0.20.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
|
|
||||||
dependencies = [
|
|
||||||
"derive_builder_macro",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "derive_builder_core"
|
|
||||||
version = "0.20.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
|
|
||||||
dependencies = [
|
|
||||||
"darling",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.118",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "derive_builder_macro"
|
|
||||||
version = "0.20.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
|
|
||||||
dependencies = [
|
|
||||||
"derive_builder_core",
|
|
||||||
"syn 2.0.118",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@@ -958,6 +957,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer 0.12.1",
|
"block-buffer 0.12.1",
|
||||||
|
"const-oid 0.10.2",
|
||||||
"crypto-common 0.2.2",
|
"crypto-common 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1474,6 +1474,12 @@ dependencies = [
|
|||||||
"byteorder",
|
"byteorder",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.14.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
@@ -1861,12 +1867,6 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ident_case"
|
|
||||||
version = "1.0.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "identity-hash"
|
name = "identity-hash"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1966,9 +1966,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iroh"
|
name = "iroh"
|
||||||
version = "1.0.1"
|
version = "1.0.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1a2e38557969901f8b356d1ebd882253bab98cc81500d53e7bcbf33f56082303"
|
checksum = "460de6bc52163b41b1646931f2897e5ab986f0966ade444467fec25024751a72"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"backon",
|
"backon",
|
||||||
"blake3",
|
"blake3",
|
||||||
@@ -2017,9 +2017,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iroh-base"
|
name = "iroh-base"
|
||||||
version = "1.0.1"
|
version = "1.0.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "61cdf012298adc13f5c2c821ad87214fecc9ac54c751301d45bf62b229a85da1"
|
checksum = "6be73e16ee21c923aca9b3121aaa0db936f7c7ecc156ff47b8dac944c68d59a8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"curve25519-dalek",
|
"curve25519-dalek",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
@@ -2077,9 +2077,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iroh-dns"
|
name = "iroh-dns"
|
||||||
version = "1.0.1"
|
version = "1.0.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4c24b83aae5ed4eced1c3724204c083c28c84c5d225a88e277eceeccce3dc3bd"
|
checksum = "46f6a9b39d18e6345f5c151afd299f2488e2cb5c520fe41b107b6bd3dc4c3349"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
@@ -2118,12 +2118,20 @@ version = "1.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef"
|
checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
"iroh-metrics-derive",
|
"iroh-metrics-derive",
|
||||||
"itoa",
|
"itoa",
|
||||||
"n0-error",
|
"n0-error",
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
|
"reqwest",
|
||||||
|
"rustls",
|
||||||
|
"rustls-platform-verifier",
|
||||||
"ryu",
|
"ryu",
|
||||||
"serde",
|
"serde",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2141,13 +2149,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iroh-relay"
|
name = "iroh-relay"
|
||||||
version = "1.0.1"
|
version = "1.0.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f16a5505939f9250297ff2f1210b5142d13618f496eab03ce3f964fc47e2e2b"
|
checksum = "24bd586cf927f7b700f56ec3639b53cb5fa901ce284784051ff71092bfbf8193"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"blake3",
|
"blake3",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
|
"clap",
|
||||||
|
"dashmap",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
@@ -2168,19 +2178,29 @@ dependencies = [
|
|||||||
"pin-project",
|
"pin-project",
|
||||||
"postcard",
|
"postcard",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
|
"rcgen",
|
||||||
|
"reloadable-state",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rustls",
|
"rustls",
|
||||||
|
"rustls-cert-file-reader",
|
||||||
|
"rustls-cert-reloadable-resolver",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_bytes",
|
"serde_bytes",
|
||||||
|
"serde_json",
|
||||||
|
"sha1 0.11.0",
|
||||||
|
"simdutf8",
|
||||||
"strum",
|
"strum",
|
||||||
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
|
"tokio-rustls-acme",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tokio-websockets",
|
"tokio-websockets",
|
||||||
|
"toml 1.1.2+spec-1.1.0",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
"url",
|
"url",
|
||||||
"vergen-gitcl",
|
|
||||||
"webpki-roots",
|
"webpki-roots",
|
||||||
"ws_stream_wasm",
|
"ws_stream_wasm",
|
||||||
]
|
]
|
||||||
@@ -2264,6 +2284,12 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
@@ -2714,9 +2740,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "noq"
|
name = "noq"
|
||||||
version = "1.0.1"
|
version = "1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4bf95190af1bd4a00a10e8255ca0c8ddd9e9a9f5e79151d7a7eb6d56aff5dc89"
|
checksum = "e11803df44ac03a30988d61585ea50885d5428e42da944fe1e498799da7886a2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
@@ -2736,9 +2762,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "noq-proto"
|
name = "noq-proto"
|
||||||
version = "1.0.1"
|
version = "1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "aa6c890013591e709a3e45dd53501351b7e27e7ff3c7e9fc3dce43e300e7e9d3"
|
checksum = "334c3c9833f7b2c573cceb9896ddc7aaeb58c8807cbb63211b24d1fe88bf866e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -2765,9 +2791,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "noq-udp"
|
name = "noq-udp"
|
||||||
version = "1.0.1"
|
version = "1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3137a52df66c20090a889828d1c655f21f52294cba64e5c4fbb04fc83eee7c8e"
|
checksum = "bde7a5d5102f1cff03d482240f0ed20551661f63663620f4b26112ed751165e9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2879,15 +2905,6 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"syn 2.0.118",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "num_threads"
|
|
||||||
version = "0.1.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2"
|
name = "objc2"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
@@ -2997,6 +3014,12 @@ dependencies = [
|
|||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opaque-debug"
|
name = "opaque-debug"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -3539,6 +3562,23 @@ version = "0.8.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "reloadable-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1dc20ac1418988b60072d783c9f68e28a173fb63493c127952f6face3b40c6e0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "reloadable-state"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3853ef78d45b50f8b989896304a85239539d39b7f866a000e8846b9b72d74ce8"
|
||||||
|
dependencies = [
|
||||||
|
"arc-swap",
|
||||||
|
"reloadable-core",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.4"
|
version = "0.13.4"
|
||||||
@@ -3562,6 +3602,8 @@ dependencies = [
|
|||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"rustls-platform-verifier",
|
"rustls-platform-verifier",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
@@ -3668,6 +3710,40 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-cert-file-reader"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8bb47c2a50fdfdaf95b0ac8b12620fc327da1fd4adbb30d0c56d866b005873ff"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-cert-read",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-cert-read"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dd46e8c5ae4de3345c4786a83f99ec7aff287209b9e26fa883c473aeb28f19d5"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-cert-reloadable-resolver"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fe1baa8a3a1f05eaa9fc55aed4342867f70e5c170ea3bfed1b38c51a4857c0c8"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"reloadable-state",
|
||||||
|
"rustls",
|
||||||
|
"rustls-cert-read",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-native-certs"
|
name = "rustls-native-certs"
|
||||||
version = "0.8.4"
|
version = "0.8.4"
|
||||||
@@ -3898,6 +3974,15 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_urlencoded"
|
name = "serde_urlencoded"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@@ -3931,6 +4016,17 @@ dependencies = [
|
|||||||
"digest 0.10.7",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.3.0",
|
||||||
|
"digest 0.11.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha1_smol"
|
name = "sha1_smol"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -4234,7 +4330,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"rsa",
|
"rsa",
|
||||||
"sha1",
|
"sha1 0.10.6",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
@@ -4454,7 +4550,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.3.4",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -4526,9 +4622,7 @@ checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"deranged",
|
"deranged",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"libc",
|
|
||||||
"num-conv",
|
"num-conv",
|
||||||
"num_threads",
|
|
||||||
"powerfmt",
|
"powerfmt",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"time-core",
|
"time-core",
|
||||||
@@ -4614,6 +4708,34 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-rustls-acme"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1af8573b15fdad8d66da116198cd8fd8d87ff62a67c1c6c3df7f62da1170793f"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"base64",
|
||||||
|
"chrono",
|
||||||
|
"futures",
|
||||||
|
"log",
|
||||||
|
"num-bigint",
|
||||||
|
"pem",
|
||||||
|
"proc-macro2",
|
||||||
|
"rcgen",
|
||||||
|
"reqwest",
|
||||||
|
"ring",
|
||||||
|
"rustls",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"webpki-roots",
|
||||||
|
"x509-parser",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-stream"
|
name = "tokio-stream"
|
||||||
version = "0.1.18"
|
version = "0.1.18"
|
||||||
@@ -4672,6 +4794,21 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "1.1.2+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde_core",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_parser",
|
||||||
|
"toml_writer",
|
||||||
|
"winnow 1.0.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "1.1.1+spec-1.1.0"
|
version = "1.1.1+spec-1.1.0"
|
||||||
@@ -4702,6 +4839,12 @@ dependencies = [
|
|||||||
"winnow 1.0.3",
|
"winnow 1.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_writer"
|
||||||
|
version = "1.1.1+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -4915,7 +5058,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"textwrap",
|
"textwrap",
|
||||||
"toml",
|
"toml 0.5.11",
|
||||||
"uniffi_internal_macros",
|
"uniffi_internal_macros",
|
||||||
"uniffi_meta",
|
"uniffi_meta",
|
||||||
"uniffi_pipeline",
|
"uniffi_pipeline",
|
||||||
@@ -4961,7 +5104,7 @@ dependencies = [
|
|||||||
"quote",
|
"quote",
|
||||||
"serde",
|
"serde",
|
||||||
"syn 2.0.118",
|
"syn 2.0.118",
|
||||||
"toml",
|
"toml 0.5.11",
|
||||||
"uniffi_meta",
|
"uniffi_meta",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5037,6 +5180,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.23.4"
|
version = "1.23.4"
|
||||||
@@ -5061,43 +5210,6 @@ version = "0.2.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "vergen"
|
|
||||||
version = "9.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"derive_builder",
|
|
||||||
"rustversion",
|
|
||||||
"vergen-lib",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "vergen-gitcl"
|
|
||||||
version = "9.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"derive_builder",
|
|
||||||
"rustversion",
|
|
||||||
"time",
|
|
||||||
"vergen",
|
|
||||||
"vergen-lib",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "vergen-lib"
|
|
||||||
version = "9.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"derive_builder",
|
|
||||||
"rustversion",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
@@ -5117,6 +5229,7 @@ dependencies = [
|
|||||||
"futures-lite",
|
"futures-lite",
|
||||||
"iroh",
|
"iroh",
|
||||||
"iroh-blobs",
|
"iroh-blobs",
|
||||||
|
"iroh-relay",
|
||||||
"irpc",
|
"irpc",
|
||||||
"irpc-iroh",
|
"irpc-iroh",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -5329,7 +5442,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
3
LICENSE
3
LICENSE
@@ -187,7 +187,8 @@
|
|||||||
same "printed page" as the copyright notice for easier
|
same "printed page" as the copyright notice for easier
|
||||||
identification within third-party archives.
|
identification within third-party archives.
|
||||||
|
|
||||||
Copyright [yyyy] [name of copyright owner]
|
Copyright 2026 VniDrop
|
||||||
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
|||||||
6
Makefile
6
Makefile
@@ -122,6 +122,12 @@ open-apple-project: apple-project ## Generate and open the native Apple Xcode pr
|
|||||||
build-apple-macos: apple-project ## Build the native macOS app (unsigned by default).
|
build-apple-macos: apple-project ## Build the native macOS app (unsigned by default).
|
||||||
cd $(ROOT)/apple && $(XCODEBUILD) -project VniDrop.xcodeproj -scheme VniDrop -configuration $(APPLE_CONFIGURATION) -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination 'platform=macOS' CODE_SIGNING_ALLOWED=$(APPLE_CODE_SIGNING) CODE_SIGNING_REQUIRED=$(APPLE_CODE_SIGNING) build
|
cd $(ROOT)/apple && $(XCODEBUILD) -project VniDrop.xcodeproj -scheme VniDrop -configuration $(APPLE_CONFIGURATION) -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination 'platform=macOS' CODE_SIGNING_ALLOWED=$(APPLE_CODE_SIGNING) CODE_SIGNING_REQUIRED=$(APPLE_CODE_SIGNING) build
|
||||||
|
|
||||||
|
build-apple-macos-direct: apple-project ## Build the direct-download macOS target (Sparkle, unsigned) — CI compile check.
|
||||||
|
cd $(ROOT)/apple && $(XCODEBUILD) -project VniDrop.xcodeproj -scheme VniDropDirect -configuration Release-Direct -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build
|
||||||
|
|
||||||
|
build-apple-dmg: ## Build the signed/notarized direct-download .dmg (see apple/RELEASE-MACOS.md for required env).
|
||||||
|
cd $(ROOT) && apple/scripts/build-dmg.sh $(VERSION)
|
||||||
|
|
||||||
open-apple: build-apple-macos ## Build and launch the native macOS app.
|
open-apple: build-apple-macos ## Build and launch the native macOS app.
|
||||||
@test -d "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app" || { printf 'Built macOS app was not found.\n' >&2; exit 1; }
|
@test -d "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app" || { printf 'Built macOS app was not found.\n' >&2; exit 1; }
|
||||||
$(OPEN) "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app"
|
$(OPEN) "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app"
|
||||||
|
|||||||
37
README.md
37
README.md
@@ -50,6 +50,42 @@ mobile networks. If a direct path cannot be established, it can forward the
|
|||||||
same end-to-end encrypted connection through a relay. The relay forwards
|
same end-to-end encrypted connection through a relay. The relay forwards
|
||||||
encrypted packets; it is not a VniDrop file store.
|
encrypted packets; it is not a VniDrop file store.
|
||||||
|
|
||||||
|
### Custom relay servers
|
||||||
|
|
||||||
|
VniDrop uses Iroh's public relay and discovery infrastructure by default. In
|
||||||
|
**Settings → Network**, users can select one of four policies:
|
||||||
|
|
||||||
|
- **Automatic (recommended):** use Iroh's public relays, with direct P2P/LAN
|
||||||
|
connections whenever possible.
|
||||||
|
- **Strict custom:** use only up to eight configured custom HTTPS relays or
|
||||||
|
direct connections. Startup reports an error if none of the custom relays can
|
||||||
|
be established.
|
||||||
|
- **Custom with direct fallback:** prefer the configured custom relays, but
|
||||||
|
continue with direct connections if they are unavailable.
|
||||||
|
- **Local only:** disable every relay and allow direct connections only,
|
||||||
|
primarily for devices on the same network.
|
||||||
|
|
||||||
|
Strict custom, custom with direct fallback, and local only never use public
|
||||||
|
relays or public discovery, including relay addresses advertised by incoming
|
||||||
|
invitations.
|
||||||
|
|
||||||
|
Applying a relay change restarts VniDrop's network engine, so active transfers
|
||||||
|
and shares must be stopped first. The app tests the new configuration and
|
||||||
|
restores the previous one if it cannot connect. Invitations created for an old
|
||||||
|
relay configuration may need to be shared again; stopped shares never expose
|
||||||
|
their stale invitations. If a long relay profile makes an invitation too large
|
||||||
|
for a QR code, use the native share action or export the invitation file.
|
||||||
|
|
||||||
|
Relay credentials embedded in URLs are deliberately rejected and bearer-token
|
||||||
|
authentication is not currently supported. A self-hosted relay must either
|
||||||
|
accept the connecting endpoints or authorize their endpoint IDs independently;
|
||||||
|
the current device ID is shown in **Settings → Network** for this purpose.
|
||||||
|
Configure the same relay profile on participating devices. A custom relay needs
|
||||||
|
a TLS certificate issued by a publicly trusted WebPKI certificate authority;
|
||||||
|
private or enterprise CAs installed only in the operating system are not used
|
||||||
|
in this version. For resilient deployments, configure at least two relays in
|
||||||
|
different failure domains.
|
||||||
|
|
||||||
## Why Iroh and `iroh-blobs`?
|
## Why Iroh and `iroh-blobs`?
|
||||||
|
|
||||||
VniDrop combines a networking layer with its own sharing rules:
|
VniDrop combines a networking layer with its own sharing rules:
|
||||||
@@ -90,6 +126,7 @@ people, especially when using **Anyone with this transfer**.
|
|||||||
- Safe receive destinations that do not silently overwrite existing files
|
- Safe receive destinations that do not silently overwrite existing files
|
||||||
- Native SwiftUI apps on iOS, iPadOS, and macOS; Compose apps on Android,
|
- Native SwiftUI apps on iOS, iPadOS, and macOS; Compose apps on Android,
|
||||||
Windows, and Linux
|
Windows, and Linux
|
||||||
|
- Strict custom HTTPS relay profiles with safe apply and rollback
|
||||||
- Opt-in diagnostics with transfer contents, invitations, and file paths
|
- Opt-in diagnostics with transfer contents, invitations, and file paths
|
||||||
excluded
|
excluded
|
||||||
|
|
||||||
|
|||||||
4
apple/.gitignore
vendored
4
apple/.gitignore
vendored
@@ -18,3 +18,7 @@ Local.xcconfig
|
|||||||
.swiftpm/
|
.swiftpm/
|
||||||
DerivedData/
|
DerivedData/
|
||||||
*.xcuserstate
|
*.xcuserstate
|
||||||
|
|
||||||
|
# Direct-download (.dmg) build outputs — apple/scripts/build-dmg.sh
|
||||||
|
.build-dmg/
|
||||||
|
dist/
|
||||||
|
|||||||
49
apple/.swiftlint.yml
Normal file
49
apple/.swiftlint.yml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Focused lint for the native app: enforce the typed-resources convention only
|
||||||
|
# (no default style rules, so this stays signal, not noise).
|
||||||
|
only_rules:
|
||||||
|
- custom_rules
|
||||||
|
|
||||||
|
included:
|
||||||
|
- VniDrop
|
||||||
|
|
||||||
|
excluded:
|
||||||
|
- VniDrop/Generated
|
||||||
|
|
||||||
|
custom_rules:
|
||||||
|
raw_localized_string:
|
||||||
|
name: "Raw localized key"
|
||||||
|
regex: 'String\(localized:\s*"'
|
||||||
|
message: "Use a typed L10n.* accessor, not a raw key string."
|
||||||
|
severity: warning
|
||||||
|
raw_localized_string_key:
|
||||||
|
name: "Raw LocalizedStringKey"
|
||||||
|
regex: 'LocalizedStringKey\("'
|
||||||
|
message: "Use a typed L10n.* accessor instead of a raw key."
|
||||||
|
severity: warning
|
||||||
|
raw_sf_symbol:
|
||||||
|
name: "Raw SF Symbol"
|
||||||
|
regex: 'system(Name|Image):\s*"'
|
||||||
|
message: "Use SFSafeSymbols: Image(systemSymbol:) or systemSymbol:."
|
||||||
|
severity: warning
|
||||||
|
raw_swiftui_string_literal:
|
||||||
|
name: "Raw SwiftUI string"
|
||||||
|
# A non-empty string literal as the leading arg of a view initializer is an
|
||||||
|
# implicit LocalizedStringKey. Empty labels (e.g. Picker("", …)) are allowed.
|
||||||
|
regex: '\b(Text|Label|Button|Toggle|Link|NavigationLink|Section|Picker|Stepper|TextField|SecureField|DisclosureGroup|Menu|GroupBox)\("[^"]'
|
||||||
|
message: "Pass a typed L10n.* accessor (or Text(verbatim:)), not a raw string literal."
|
||||||
|
severity: warning
|
||||||
|
raw_alert_message:
|
||||||
|
name: "Raw NFC/alert message"
|
||||||
|
# User-facing UIKit/CoreNFC prompts (e.g. NFCReaderSession.alertMessage) must
|
||||||
|
# be localized, not hardcoded English.
|
||||||
|
regex: '\balertMessage\s*=\s*"'
|
||||||
|
message: "Assign a localized value (String(localized: L10n.*)), not a raw string literal."
|
||||||
|
severity: warning
|
||||||
|
raw_invitation_error:
|
||||||
|
name: "Raw InvitationError literal"
|
||||||
|
# InvitationError.raw is the escape hatch for genuinely dynamic system/core
|
||||||
|
# messages; a string literal here is a loose user-facing string that belongs
|
||||||
|
# in a typed InvitationError case mapped to L10n in UserFacingError.swift.
|
||||||
|
regex: 'InvitationError\.raw\("'
|
||||||
|
message: "Add a typed InvitationError case + L10n mapping instead of a literal .raw(\"…\")."
|
||||||
|
severity: warning
|
||||||
@@ -34,12 +34,30 @@ Prerequisites: Xcode, Rust with the Apple targets
|
|||||||
make apple-core # Rust core, Swift bindings, and XCFramework
|
make apple-core # Rust core, Swift bindings, and XCFramework
|
||||||
make apple-project # generate apple/VniDrop.xcodeproj
|
make apple-project # generate apple/VniDrop.xcodeproj
|
||||||
make open-apple-project # generate and open the project in Xcode
|
make open-apple-project # generate and open the project in Xcode
|
||||||
make build-apple-macos # unsigned macOS build
|
make build-apple-macos # unsigned macOS build (App Store target)
|
||||||
make open-apple # build and launch the macOS app
|
make open-apple # build and launch the macOS app
|
||||||
make build-apple-ios # unsigned iOS simulator build
|
make build-apple-ios # unsigned iOS simulator app
|
||||||
make check-apple # iOS simulator tests
|
make check-apple # iOS simulator tests
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### macOS shipping channels
|
||||||
|
|
||||||
|
The macOS app ships through two targets that build identical sources:
|
||||||
|
|
||||||
|
- **`VniDrop`** (`Release`) — Mac App Store / TestFlight. Sandboxed, no
|
||||||
|
self-updater.
|
||||||
|
- **`VniDropDirect`** (`Release-Direct`) — direct-download `.dmg` on GitHub
|
||||||
|
Releases + Homebrew cask. Adds the **Sparkle** auto-updater behind the
|
||||||
|
`DIRECT_DISTRIBUTION` compile flag, so the App Store binary never links Sparkle.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build-apple-macos-direct # unsigned compile-check of the direct target
|
||||||
|
make build-apple-dmg VERSION=x.y.z # signed (+ notarized) .dmg
|
||||||
|
```
|
||||||
|
|
||||||
|
Full signing, notarization, appcast, and cask flow: see
|
||||||
|
[`RELEASE-MACOS.md`](RELEASE-MACOS.md).
|
||||||
|
|
||||||
Use `APPLE_PROFILE=release` to request a release Rust core, or set
|
Use `APPLE_PROFILE=release` to request a release Rust core, or set
|
||||||
`APPLE_DESTINATION` to override the automatically selected iOS simulator.
|
`APPLE_DESTINATION` to override the automatically selected iOS simulator.
|
||||||
Code signing is disabled for the app and test targets; local and CI builds do
|
Code signing is disabled for the app and test targets; local and CI builds do
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ final class AppModelTests: XCTestCase {
|
|||||||
_ = makeModel(core, preferences: Fixtures.preferences())
|
_ = makeModel(core, preferences: Fixtures.preferences())
|
||||||
await waitUntil { core.state.isInitialized }
|
await waitUntil { core.state.isInitialized }
|
||||||
XCTAssertTrue(core.state.isInitialized)
|
XCTAssertTrue(core.state.isInitialized)
|
||||||
|
XCTAssertEqual(core.initializedNetworkConfigurations, [.automatic])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInitializesCoreWithSavedCustomRelayConfiguration() async {
|
||||||
|
let core = FakeCoreGateway()
|
||||||
|
let preferences = Fixtures.preferences()
|
||||||
|
let configuration = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"])
|
||||||
|
preferences.setRelayConfiguration(configuration)
|
||||||
|
_ = makeModel(core, preferences: preferences)
|
||||||
|
|
||||||
|
await waitUntil { core.state.isInitialized }
|
||||||
|
XCTAssertEqual(core.initializedNetworkConfigurations, [configuration])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSelectDestination() {
|
func testSelectDestination() {
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ final class AppPreferencesRepositoryTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testFallbacksWhenEmpty() {
|
func testMissingRelayProfileDefaultsToAutomatic() {
|
||||||
let repo = AppPreferencesRepository(defaults: defaults(), fallback: fallback())
|
let repo = AppPreferencesRepository(defaults: defaults(), fallback: fallback())
|
||||||
XCTAssertEqual(repo.preferences.username, "Default")
|
XCTAssertEqual(repo.preferences.username, "Default")
|
||||||
XCTAssertEqual(repo.preferences.themeMode, .system)
|
XCTAssertEqual(repo.preferences.themeMode, .system)
|
||||||
|
XCTAssertEqual(repo.preferences.relayConfiguration, .automatic)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testValuesPersistAndReload() {
|
func testValuesPersistAndReload() {
|
||||||
@@ -28,6 +29,10 @@ final class AppPreferencesRepositoryTests: XCTestCase {
|
|||||||
repo.setUsername("Bob")
|
repo.setUsername("Bob")
|
||||||
repo.setThemeMode(.dark)
|
repo.setThemeMode(.dark)
|
||||||
repo.setReceiveFolder(ReceiveFolder(kind: .iosSecurityScopedUrl, value: "file:///x", displayName: "Custom"))
|
repo.setReceiveFolder(ReceiveFolder(kind: .iosSecurityScopedUrl, value: "file:///x", displayName: "Custom"))
|
||||||
|
repo.setRelayConfiguration(RelayConfiguration(
|
||||||
|
mode: .strictCustom,
|
||||||
|
relayURLs: ["https://relay-one.example", "https://relay-two.example:443"]
|
||||||
|
))
|
||||||
|
|
||||||
// A fresh repository over the same store reflects the persisted values.
|
// A fresh repository over the same store reflects the persisted values.
|
||||||
let reloaded = AppPreferencesRepository(defaults: store, fallback: fb)
|
let reloaded = AppPreferencesRepository(defaults: store, fallback: fb)
|
||||||
@@ -35,6 +40,40 @@ final class AppPreferencesRepositoryTests: XCTestCase {
|
|||||||
XCTAssertEqual(reloaded.preferences.themeMode, .dark)
|
XCTAssertEqual(reloaded.preferences.themeMode, .dark)
|
||||||
XCTAssertEqual(reloaded.preferences.receiveFolder.displayName, "Custom")
|
XCTAssertEqual(reloaded.preferences.receiveFolder.displayName, "Custom")
|
||||||
XCTAssertEqual(reloaded.preferences.receiveFolder.kind, .iosSecurityScopedUrl)
|
XCTAssertEqual(reloaded.preferences.receiveFolder.kind, .iosSecurityScopedUrl)
|
||||||
|
XCTAssertEqual(reloaded.preferences.relayConfiguration, RelayConfiguration(
|
||||||
|
mode: .strictCustom,
|
||||||
|
relayURLs: ["https://relay-one.example", "https://relay-two.example:443"]
|
||||||
|
))
|
||||||
|
XCTAssertNotNil(store.data(forKey: "relay_configuration"))
|
||||||
|
XCTAssertNil(store.object(forKey: "relay_mode"))
|
||||||
|
XCTAssertNil(store.object(forKey: "relay_urls"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCorruptedRelayProfileFailsClosed() {
|
||||||
|
let store = defaults()
|
||||||
|
store.set(Data("{".utf8), forKey: "relay_configuration")
|
||||||
|
|
||||||
|
let repo = AppPreferencesRepository(defaults: store, fallback: fallback())
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
repo.preferences.relayConfiguration,
|
||||||
|
RelayConfiguration(mode: .strictCustom, relayURLs: [])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnknownRelayModeFailsClosed() {
|
||||||
|
let store = defaults()
|
||||||
|
store.set(
|
||||||
|
Data(#"{"mode":"future-mode","relayURLs":["https://relay.example"]}"#.utf8),
|
||||||
|
forKey: "relay_configuration"
|
||||||
|
)
|
||||||
|
|
||||||
|
let repo = AppPreferencesRepository(defaults: store, fallback: fallback())
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
repo.preferences.relayConfiguration,
|
||||||
|
RelayConfiguration(mode: .strictCustom, relayURLs: [])
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testResetReceiveFolderRestoresFallback() {
|
func testResetReceiveFolderRestoresFallback() {
|
||||||
|
|||||||
131
apple/Tests/CoreRepositoryLifecycleTests.swift
Normal file
131
apple/Tests/CoreRepositoryLifecycleTests.swift
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@preconcurrency import VnidropCore
|
||||||
|
@testable import VniDrop
|
||||||
|
|
||||||
|
private enum BlockingCoreFactoryError: Error {
|
||||||
|
case stopped
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class BlockingCoreBindingFactory: CoreBindingFactory, @unchecked Sendable {
|
||||||
|
private let release = DispatchSemaphore(value: 0)
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var initializeCallCount = 0
|
||||||
|
private var initializationStarted = false
|
||||||
|
private var startWaiters: [CheckedContinuation<Void, Never>] = []
|
||||||
|
|
||||||
|
var callCount: Int {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return initializeCallCount
|
||||||
|
}
|
||||||
|
|
||||||
|
func initialize(
|
||||||
|
appDataDir: String,
|
||||||
|
eventSink: CoreEventSink,
|
||||||
|
networkConfiguration: RelayConfiguration
|
||||||
|
) throws -> VnidropCore {
|
||||||
|
lock.lock()
|
||||||
|
initializeCallCount += 1
|
||||||
|
let call = initializeCallCount
|
||||||
|
initializationStarted = true
|
||||||
|
let waiters = startWaiters
|
||||||
|
startWaiters.removeAll()
|
||||||
|
lock.unlock()
|
||||||
|
waiters.forEach { $0.resume() }
|
||||||
|
|
||||||
|
if call == 1 {
|
||||||
|
release.wait()
|
||||||
|
}
|
||||||
|
throw BlockingCoreFactoryError.stopped
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitUntilInitializationStarts() async {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
lock.lock()
|
||||||
|
if initializationStarted {
|
||||||
|
lock.unlock()
|
||||||
|
continuation.resume()
|
||||||
|
} else {
|
||||||
|
startWaiters.append(continuation)
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unblockInitialization() {
|
||||||
|
release.signal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class CoreRepositoryLifecycleTests: XCTestCase {
|
||||||
|
func testIdleRequirementRejectsTransfersAndShares() throws {
|
||||||
|
XCTAssertNoThrow(try CoreNetworkLifecycle.requireIdle(activeTransfers: 0, activeShares: 0))
|
||||||
|
XCTAssertThrowsError(
|
||||||
|
try CoreNetworkLifecycle.requireIdle(activeTransfers: 1, activeShares: 0)
|
||||||
|
) { error in
|
||||||
|
XCTAssertEqual(error as? CoreNetworkLifecycleError, .activeNetworkWork)
|
||||||
|
}
|
||||||
|
XCTAssertThrowsError(
|
||||||
|
try CoreNetworkLifecycle.requireIdle(activeTransfers: 0, activeShares: 1)
|
||||||
|
) { error in
|
||||||
|
XCTAssertEqual(error as? CoreNetworkLifecycleError, .activeNetworkWork)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRestartSerializesInitializationAndRejectsNewNetworkWork() async {
|
||||||
|
let factory = BlockingCoreBindingFactory()
|
||||||
|
let repository = CoreRepository(coreFactory: factory)
|
||||||
|
let firstInitialization = Task {
|
||||||
|
await repository.initialize(appDataDir: "/tmp/first", networkConfiguration: .automatic)
|
||||||
|
}
|
||||||
|
await factory.waitUntilInitializationStarts()
|
||||||
|
|
||||||
|
let safetyRelease = Task.detached {
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
factory.unblockInitialization()
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
safetyRelease.cancel()
|
||||||
|
factory.unblockInitialization()
|
||||||
|
}
|
||||||
|
|
||||||
|
let concurrentInitialization = await repository.initialize(
|
||||||
|
appDataDir: "/tmp/second",
|
||||||
|
networkConfiguration: RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"])
|
||||||
|
)
|
||||||
|
assertLifecycleFailure(concurrentInitialization, equals: .transitionInProgress)
|
||||||
|
|
||||||
|
let share = await repository.shareSources(
|
||||||
|
[],
|
||||||
|
transferName: "Blocked",
|
||||||
|
senderName: "Tester",
|
||||||
|
accessPolicy: .requireApproval
|
||||||
|
)
|
||||||
|
assertLifecycleFailure(share, equals: .transitionInProgress)
|
||||||
|
|
||||||
|
let receive = await repository.receive(ticket: "ticket", outputDir: "/tmp", receiverName: "Tester")
|
||||||
|
assertLifecycleFailure(receive, equals: .transitionInProgress)
|
||||||
|
XCTAssertEqual(factory.callCount, 1)
|
||||||
|
|
||||||
|
factory.unblockInitialization()
|
||||||
|
guard case .failure(let error) = await firstInitialization.value else {
|
||||||
|
return XCTFail("The blocking factory should fail the first initialization")
|
||||||
|
}
|
||||||
|
XCTAssertTrue(error is BlockingCoreFactoryError)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func assertLifecycleFailure<T>(
|
||||||
|
_ result: Result<T, Error>,
|
||||||
|
equals expected: CoreNetworkLifecycleError,
|
||||||
|
file: StaticString = #filePath,
|
||||||
|
line: UInt = #line
|
||||||
|
) {
|
||||||
|
guard case .failure(let error) = result else {
|
||||||
|
return XCTFail("Expected lifecycle failure \(expected)", file: file, line: line)
|
||||||
|
}
|
||||||
|
XCTAssertEqual(error as? CoreNetworkLifecycleError, expected, file: file, line: line)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ final class FakeCoreGateway: CoreGateway {
|
|||||||
var cancelResult: Result<Void, Error> = .success(())
|
var cancelResult: Result<Void, Error> = .success(())
|
||||||
var deleteResult: Result<Void, Error> = .success(())
|
var deleteResult: Result<Void, Error> = .success(())
|
||||||
var clearReceiveHistoryResult: Result<UInt64, Error> = .success(0)
|
var clearReceiveHistoryResult: Result<UInt64, Error> = .success(0)
|
||||||
|
var initializeResult: Result<Void, Error> = .success(())
|
||||||
|
var initializeResults: [Result<Void, Error>] = []
|
||||||
|
|
||||||
// Recorded calls
|
// Recorded calls
|
||||||
private(set) var responses: [(id: String, accepted: Bool, reason: String?)] = []
|
private(set) var responses: [(id: String, accepted: Bool, reason: String?)] = []
|
||||||
@@ -35,11 +37,18 @@ final class FakeCoreGateway: CoreGateway {
|
|||||||
private(set) var lastReceiveTicket: String?
|
private(set) var lastReceiveTicket: String?
|
||||||
private(set) var lastReceiveReceiverName: String?
|
private(set) var lastReceiveReceiverName: String?
|
||||||
private(set) var lastShareAccessPolicy: ShareAccessPolicy?
|
private(set) var lastShareAccessPolicy: ShareAccessPolicy?
|
||||||
|
private(set) var initializedNetworkConfigurations: [RelayConfiguration] = []
|
||||||
|
|
||||||
func setState(_ state: CoreState) { stateSubject.send(state) }
|
func setState(_ state: CoreState) { stateSubject.send(state) }
|
||||||
func emit(_ signal: CoreSignal) { signalsSubject.send(signal) }
|
func emit(_ signal: CoreSignal) { signalsSubject.send(signal) }
|
||||||
|
|
||||||
func initialize(appDataDir: String) async -> Result<Void, Error> {
|
func initialize(
|
||||||
|
appDataDir: String,
|
||||||
|
networkConfiguration: RelayConfiguration
|
||||||
|
) async -> Result<Void, Error> {
|
||||||
|
initializedNetworkConfigurations.append(networkConfiguration)
|
||||||
|
let result = initializeResults.isEmpty ? initializeResult : initializeResults.removeFirst()
|
||||||
|
guard case .success = result else { return result }
|
||||||
var s = stateSubject.value
|
var s = stateSubject.value
|
||||||
s.isInitialized = true
|
s.isInitialized = true
|
||||||
stateSubject.send(s)
|
stateSubject.send(s)
|
||||||
|
|||||||
121
apple/Tests/RelayConfigurationTests.swift
Normal file
121
apple/Tests/RelayConfigurationTests.swift
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import VniDrop
|
||||||
|
|
||||||
|
final class RelayConfigurationTests: XCTestCase {
|
||||||
|
func testCustomFallbackValidatesAndPreservesItsMode() throws {
|
||||||
|
let result = try RelayConfigurationValidator.validate(
|
||||||
|
mode: .customWithDirectFallback,
|
||||||
|
relayURLs: ["https://relay.example/"]
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
result,
|
||||||
|
RelayConfiguration(
|
||||||
|
mode: .customWithDirectFallback,
|
||||||
|
relayURLs: ["https://relay.example"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLocalOnlyRetainsPreviouslySavedRelayURLs() throws {
|
||||||
|
let retained = ["https://relay.example"]
|
||||||
|
let result = try RelayConfigurationValidator.validate(
|
||||||
|
mode: .localOnly,
|
||||||
|
relayURLs: ["not a URL"],
|
||||||
|
retainedRelayURLs: retained
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(result, RelayConfiguration(mode: .localOnly, relayURLs: retained))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAutomaticModeIgnoresRelayDrafts() throws {
|
||||||
|
let result = try RelayConfigurationValidator.validate(
|
||||||
|
mode: .automatic,
|
||||||
|
relayURLs: ["not a URL"]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(result, .automatic)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAutomaticModeRetainsPreviouslySavedRelayURLs() throws {
|
||||||
|
let result = try RelayConfigurationValidator.validate(
|
||||||
|
mode: .automatic,
|
||||||
|
relayURLs: ["not a URL"],
|
||||||
|
retainedRelayURLs: ["https://relay.example"]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(result, RelayConfiguration(
|
||||||
|
mode: .automatic,
|
||||||
|
relayURLs: ["https://relay.example"]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCustomModeTrimsValidHTTPSRelayURLs() throws {
|
||||||
|
let result = try RelayConfigurationValidator.validate(
|
||||||
|
mode: .strictCustom,
|
||||||
|
relayURLs: [" https://relay.example/ ", "https://backup.example:443"]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(result, RelayConfiguration(
|
||||||
|
mode: .strictCustom,
|
||||||
|
relayURLs: ["https://relay.example", "https://backup.example"]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCustomModeIgnoresEmptyURLRows() throws {
|
||||||
|
let result = try RelayConfigurationValidator.validate(
|
||||||
|
mode: .strictCustom,
|
||||||
|
relayURLs: ["", " ", "https://relay.example"]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(result.relayURLs, ["https://relay.example"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCustomModeRequiresAtLeastOneRelay() {
|
||||||
|
XCTAssertThrowsError(try RelayConfigurationValidator.validate(mode: .strictCustom, relayURLs: [])) { error in
|
||||||
|
XCTAssertEqual(error as? RelayConfigurationValidationError, .missingURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCustomModeRequiresHTTPS() {
|
||||||
|
XCTAssertThrowsError(try RelayConfigurationValidator.validate(
|
||||||
|
mode: .strictCustom,
|
||||||
|
relayURLs: ["http://relay.example"]
|
||||||
|
)) { error in
|
||||||
|
XCTAssertEqual(error as? RelayConfigurationValidationError, .httpsRequired(index: 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCustomModeRejectsCredentialsQueryFragmentAndPath() {
|
||||||
|
let invalidURLs = [
|
||||||
|
"https://user:password@relay.example",
|
||||||
|
"https://relay.example?token=secret",
|
||||||
|
"https://relay.example#fragment",
|
||||||
|
"https://relay.example/custom/path",
|
||||||
|
"https://relay.example:0",
|
||||||
|
"https://relay.example:99999",
|
||||||
|
]
|
||||||
|
for relayURL in invalidURLs {
|
||||||
|
XCTAssertThrowsError(
|
||||||
|
try RelayConfigurationValidator.validate(mode: .strictCustom, relayURLs: [relayURL]),
|
||||||
|
"Expected \(relayURL) to be rejected"
|
||||||
|
) { error in
|
||||||
|
XCTAssertEqual(error as? RelayConfigurationValidationError, .invalidURL(index: 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCustomModeRejectsNormalizedDuplicate() {
|
||||||
|
XCTAssertThrowsError(try RelayConfigurationValidator.validate(
|
||||||
|
mode: .strictCustom,
|
||||||
|
relayURLs: ["https://relay.example", "https://RELAY.example:443/"]
|
||||||
|
)) { error in
|
||||||
|
XCTAssertEqual(error as? RelayConfigurationValidationError, .duplicateURL(index: 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCustomModeRejectsMoreThanEightRelays() {
|
||||||
|
let relayURLs = (0...RelayConfigurationValidator.maximumRelayCount).map {
|
||||||
|
"https://relay-\($0).example"
|
||||||
|
}
|
||||||
|
XCTAssertThrowsError(try RelayConfigurationValidator.validate(mode: .strictCustom, relayURLs: relayURLs)) { error in
|
||||||
|
XCTAssertEqual(error as? RelayConfigurationValidationError, .tooManyURLs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,4 +58,25 @@ final class SendModelTests: XCTestCase {
|
|||||||
let response = core.responses.first { $0.id == "req-1" }
|
let response = core.responses.first { $0.id == "req-1" }
|
||||||
XCTAssertEqual(response?.accepted, false)
|
XCTAssertEqual(response?.accepted, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testOnlyActiveShareExposesStoredInvitationTicket() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
Fixtures.transfer(id: 1, direction: .send, status: .sharing).invitationPresentation,
|
||||||
|
.ready("ticket")
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
Fixtures.transfer(id: 2, direction: .send, status: .importing).invitationPresentation,
|
||||||
|
.preparing
|
||||||
|
)
|
||||||
|
for status in [TransferStatus.stopped, .failed, .cancelled, .done] {
|
||||||
|
XCTAssertEqual(
|
||||||
|
Fixtures.transfer(id: 3, direction: .send, status: status).invitationPresentation,
|
||||||
|
.unavailable
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOversizedInvitationReportsQRCodeUnavailable() {
|
||||||
|
XCTAssertNil(QRCode.generate(from: String(repeating: "x", count: 10_000)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,4 +42,107 @@ final class SettingsModelTests: XCTestCase {
|
|||||||
await waitUntil { core.deletedTransfers.count == 2 }
|
await waitUntil { core.deletedTransfers.count == 2 }
|
||||||
XCTAssertEqual(Set(core.deletedTransfers), [2, 3])
|
XCTAssertEqual(Set(core.deletedTransfers), [2, 3])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testNetworkSettingsExposeCurrentEndpointId() {
|
||||||
|
let core = FakeCoreGateway()
|
||||||
|
let model = makeModel(core, preferences: Fixtures.preferences())
|
||||||
|
|
||||||
|
core.setState(CoreState(
|
||||||
|
isInitialized: true,
|
||||||
|
status: CoreStatus(endpointId: "endpoint-for-allowlist", activeTransfers: 0, activeShares: 0)
|
||||||
|
))
|
||||||
|
|
||||||
|
XCTAssertEqual(model.state.endpointId, "endpoint-for-allowlist")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testApplyCustomRelayRestartsCoreThenPersistsConfiguration() async {
|
||||||
|
let core = FakeCoreGateway()
|
||||||
|
let preferences = Fixtures.preferences()
|
||||||
|
let model = makeModel(core, preferences: preferences)
|
||||||
|
model.setRelayMode(.strictCustom)
|
||||||
|
model.setRelayURL(" https://relay.example/ ", at: 0)
|
||||||
|
|
||||||
|
model.applyRelayConfiguration()
|
||||||
|
|
||||||
|
await waitUntil { preferences.preferences.relayConfiguration.mode == .strictCustom }
|
||||||
|
let expected = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"])
|
||||||
|
XCTAssertEqual(preferences.preferences.relayConfiguration, expected)
|
||||||
|
XCTAssertEqual(core.initializedNetworkConfigurations, [expected])
|
||||||
|
XCTAssertFalse(model.state.relayConfigurationIsDirty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testApplyingAutomaticRetainsLastCustomRelayURLs() async {
|
||||||
|
let core = FakeCoreGateway()
|
||||||
|
let preferences = Fixtures.preferences()
|
||||||
|
let relayURLs = ["https://relay.example", "https://backup.example"]
|
||||||
|
preferences.setRelayConfiguration(RelayConfiguration(mode: .strictCustom, relayURLs: relayURLs))
|
||||||
|
let model = makeModel(core, preferences: preferences)
|
||||||
|
|
||||||
|
model.setRelayMode(.automatic)
|
||||||
|
model.applyRelayConfiguration()
|
||||||
|
|
||||||
|
await waitUntil { preferences.preferences.relayConfiguration.mode == .automatic }
|
||||||
|
XCTAssertEqual(preferences.preferences.relayConfiguration.relayURLs, relayURLs)
|
||||||
|
XCTAssertEqual(core.initializedNetworkConfigurations, [
|
||||||
|
RelayConfiguration(mode: .automatic, relayURLs: relayURLs),
|
||||||
|
])
|
||||||
|
model.setRelayMode(.strictCustom)
|
||||||
|
XCTAssertEqual(model.state.relayURLs, relayURLs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testApplyRelayIsBlockedWhileShareIsActive() async {
|
||||||
|
let core = FakeCoreGateway()
|
||||||
|
let preferences = Fixtures.preferences()
|
||||||
|
let model = makeModel(core, preferences: preferences)
|
||||||
|
core.setState(CoreState(
|
||||||
|
isInitialized: true,
|
||||||
|
status: CoreStatus(endpointId: "endpoint", activeTransfers: 0, activeShares: 1)
|
||||||
|
))
|
||||||
|
model.setRelayMode(.strictCustom)
|
||||||
|
model.setRelayURL("https://relay.example", at: 0)
|
||||||
|
|
||||||
|
model.applyRelayConfiguration()
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
XCTAssertTrue(core.initializedNetworkConfigurations.isEmpty)
|
||||||
|
XCTAssertEqual(preferences.preferences.relayConfiguration, .automatic)
|
||||||
|
XCTAssertEqual(model.state.relayApplyErrorKey, "relay_apply_active_transfers")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRepositoryActiveWorkRejectionDoesNotAttemptRollback() async {
|
||||||
|
let core = FakeCoreGateway()
|
||||||
|
core.initializeResult = .failure(CoreNetworkLifecycleError.activeNetworkWork)
|
||||||
|
let preferences = Fixtures.preferences()
|
||||||
|
let model = makeModel(core, preferences: preferences)
|
||||||
|
let attempted = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"])
|
||||||
|
model.setRelayMode(.strictCustom)
|
||||||
|
model.setRelayURL(attempted.relayURLs[0], at: 0)
|
||||||
|
|
||||||
|
model.applyRelayConfiguration()
|
||||||
|
await waitUntil {
|
||||||
|
core.initializedNetworkConfigurations.count == 1 && !model.state.isApplyingRelayConfiguration
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertEqual(core.initializedNetworkConfigurations, [attempted])
|
||||||
|
XCTAssertEqual(preferences.preferences.relayConfiguration, .automatic)
|
||||||
|
XCTAssertTrue(model.state.hasActiveNetworkWork)
|
||||||
|
XCTAssertEqual(model.state.relayApplyErrorKey, "relay_apply_active_transfers")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFailedRelayApplyRollsBackWithoutPersisting() async {
|
||||||
|
let core = FakeCoreGateway()
|
||||||
|
core.initializeResults = [.failure(TestError.unimplemented), .success(())]
|
||||||
|
let preferences = Fixtures.preferences()
|
||||||
|
let model = makeModel(core, preferences: preferences)
|
||||||
|
let attempted = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"])
|
||||||
|
model.setRelayMode(.strictCustom)
|
||||||
|
model.setRelayURL(attempted.relayURLs[0], at: 0)
|
||||||
|
|
||||||
|
model.applyRelayConfiguration()
|
||||||
|
await waitUntil { core.initializedNetworkConfigurations.count == 2 }
|
||||||
|
|
||||||
|
XCTAssertEqual(core.initializedNetworkConfigurations, [attempted, .automatic])
|
||||||
|
XCTAssertEqual(preferences.preferences.relayConfiguration, .automatic)
|
||||||
|
XCTAssertEqual(model.state.relayApplyErrorKey, "relay_apply_failed")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,4 +41,11 @@ final class TransferNotificationTests: XCTestCase {
|
|||||||
let requests = [Fixtures.request(id: "a", requestedAt: 1, status: .completed)]
|
let requests = [Fixtures.request(id: "a", requestedAt: 1, status: .completed)]
|
||||||
XCTAssertTrue(plannedReceiverNotifications(requests, published: ["receiver-completed-a"]).isEmpty)
|
XCTAssertTrue(plannedReceiverNotifications(requests, published: ["receiver-completed-a"]).isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testReceiverNotificationsFireForFailedReceivers() {
|
||||||
|
let requests = [Fixtures.request(id: "x", requestedAt: 1, status: .failed)]
|
||||||
|
let planned = plannedReceiverNotifications(requests, published: [])
|
||||||
|
XCTAssertEqual(planned.map(\.id), ["receiver-failed-x"])
|
||||||
|
XCTAssertEqual(planned.first?.kind, .receiverFailed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ final class UiMessageControllerTests: XCTestCase {
|
|||||||
|
|
||||||
func testErrorSuppressesUserCancellation() {
|
func testErrorSuppressesUserCancellation() {
|
||||||
let c = UiMessageController()
|
let c = UiMessageController()
|
||||||
c.error(InvitationError.message("QR scanning was cancelled"))
|
c.error(InvitationError.cancelled)
|
||||||
XCTAssertNil(c.current) // cancellations are swallowed
|
XCTAssertNil(c.current) // cancellations are swallowed
|
||||||
}
|
}
|
||||||
|
|
||||||
func testErrorShowsNonCancellation() {
|
func testErrorShowsNonCancellation() {
|
||||||
let c = UiMessageController()
|
let c = UiMessageController()
|
||||||
c.error(InvitationError.message("The transfer was refused"))
|
c.error(InvitationError.raw("The transfer was refused"))
|
||||||
XCTAssertEqual(c.current?.tone, .error)
|
XCTAssertEqual(c.current?.tone, .error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,20 +36,23 @@ final class UiMessageControllerTests: XCTestCase {
|
|||||||
final class UserFacingErrorTests: XCTestCase {
|
final class UserFacingErrorTests: XCTestCase {
|
||||||
|
|
||||||
func testIsUserCancellation() {
|
func testIsUserCancellation() {
|
||||||
XCTAssertTrue(InvitationError.message("NFC reading was cancelled").isUserCancellation)
|
XCTAssertTrue(InvitationError.cancelled.isUserCancellation)
|
||||||
XCTAssertTrue(InvitationError.message("User canceled the picker").isUserCancellation)
|
XCTAssertTrue(InvitationError.raw("User canceled the picker").isUserCancellation)
|
||||||
XCTAssertFalse(InvitationError.message("A database error occurred").isUserCancellation)
|
XCTAssertFalse(InvitationError.raw("A database error occurred").isUserCancellation)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testToUiTextMapsKnownReasons() {
|
func testToUiTextMapsKnownReasons() {
|
||||||
XCTAssertEqual(InvitationError.message("The transfer was refused").toUiText(), .resource(L10n.Error.permission))
|
// Typed cases map directly at the UI boundary.
|
||||||
XCTAssertEqual(InvitationError.message("invalid ticket").toUiText(), .resource(L10n.Error.invalidTicket))
|
XCTAssertEqual(InvitationError.shareEmpty.toUiText(), .resource(L10n.Error.shareEmpty))
|
||||||
XCTAssertEqual(InvitationError.message("Select at least one file to share").toUiText(), .resource(L10n.Error.shareEmpty))
|
XCTAssertEqual(InvitationError.cameraUnavailable.toUiText(), .resource(L10n.Error.camera))
|
||||||
XCTAssertEqual(InvitationError.message("Camera access is required").toUiText(), .resource(L10n.Error.camera))
|
XCTAssertEqual(InvitationError.nfcFailed.toUiText(), .resource(L10n.Error.nfc))
|
||||||
|
// Dynamic `.raw` payloads still fall through the substring hints.
|
||||||
|
XCTAssertEqual(InvitationError.raw("The transfer was refused").toUiText(), .resource(L10n.Error.permission))
|
||||||
|
XCTAssertEqual(InvitationError.raw("invalid ticket").toUiText(), .resource(L10n.Error.invalidTicket))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testToUiTextFallsBackToGeneric() {
|
func testToUiTextFallsBackToGeneric() {
|
||||||
XCTAssertEqual(InvitationError.message("something entirely unexpected").toUiText(), .resource(L10n.Error.generic))
|
XCTAssertEqual(InvitationError.raw("something entirely unexpected").toUiText(), .resource(L10n.Error.generic))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testToUiTextMapsTypedTransferFailures() {
|
func testToUiTextMapsTypedTransferFailures() {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ final class AppGraph: ObservableObject {
|
|||||||
let filePreviewRepository: FilePreviewRepository
|
let filePreviewRepository: FilePreviewRepository
|
||||||
let approvalCoordinator: ApprovalCoordinator
|
let approvalCoordinator: ApprovalCoordinator
|
||||||
let transferNotificationCoordinator: TransferNotificationCoordinator
|
let transferNotificationCoordinator: TransferNotificationCoordinator
|
||||||
|
let backgroundActivity: BackgroundActivityController
|
||||||
|
|
||||||
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) {
|
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) {
|
||||||
self.dependencies = dependencies
|
self.dependencies = dependencies
|
||||||
@@ -39,6 +40,7 @@ final class AppGraph: ObservableObject {
|
|||||||
visibility: visibility,
|
visibility: visibility,
|
||||||
messages: messages
|
messages: messages
|
||||||
)
|
)
|
||||||
|
self.backgroundActivity = BackgroundActivityController(repository: coreRepository)
|
||||||
AppLogger.info("lifecycle", "graph created", ["platform": dependencies.environment.name])
|
AppLogger.info("lifecycle", "graph created", ["platform": dependencies.environment.name])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ struct RootView: View {
|
|||||||
|
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
|
/// Drives the approval sheet; toggled from the pending-approval `onChange` so the
|
||||||
|
/// presentation can be deferred until the Share/QR sheet has dismissed on macOS.
|
||||||
|
@State private var showApproval = false
|
||||||
|
|
||||||
init(dependencies: AppDependencies) {
|
init(dependencies: AppDependencies) {
|
||||||
let graph = AppGraph(dependencies: dependencies)
|
let graph = AppGraph(dependencies: dependencies)
|
||||||
_graph = StateObject(wrappedValue: graph)
|
_graph = StateObject(wrappedValue: graph)
|
||||||
@@ -58,11 +62,20 @@ struct RootView: View {
|
|||||||
navigation(windowClass: windowClass)
|
navigation(windowClass: windowClass)
|
||||||
SnackbarHost(controller: messages)
|
SnackbarHost(controller: messages)
|
||||||
ApprovalModalHost(
|
ApprovalModalHost(
|
||||||
|
isPresented: $showApproval,
|
||||||
state: approvals.state,
|
state: approvals.state,
|
||||||
onAccept: approvals.accept,
|
onAccept: approvals.accept,
|
||||||
onRefuse: approvals.refuse
|
onRefuse: approvals.refuse
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.overlay {
|
||||||
|
// A small, unobtrusive indicator while the core finishes its async
|
||||||
|
// startup — otherwise the lists look empty and the app feels stalled.
|
||||||
|
if !sendModel.coreState.isInitialized {
|
||||||
|
CoreStartingOverlay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.25), value: sendModel.coreState.isInitialized)
|
||||||
.vniDropTheme(isDark: isDark)
|
.vniDropTheme(isDark: isDark)
|
||||||
.preferredColorScheme(appModel.themeMode.preferredColorScheme)
|
.preferredColorScheme(appModel.themeMode.preferredColorScheme)
|
||||||
.environment(\.vniColors, isDark ? .dark : .light)
|
.environment(\.vniColors, isDark ? .dark : .light)
|
||||||
@@ -73,22 +86,43 @@ struct RootView: View {
|
|||||||
switch phase {
|
switch phase {
|
||||||
case .active:
|
case .active:
|
||||||
graph.visibility.setForeground(true)
|
graph.visibility.setForeground(true)
|
||||||
|
graph.backgroundActivity.didBecomeForeground()
|
||||||
settingsModel.refreshNotificationPermission()
|
settingsModel.refreshNotificationPermission()
|
||||||
// Reconcile against the durable snapshot: while the window was
|
// Reconcile against the durable snapshot: while the window was
|
||||||
// unfocused/occluded (common on macOS) live events may not have
|
// unfocused/occluded (common on macOS) live events may not have
|
||||||
// rendered, leaving progress/status stale.
|
// rendered, leaving progress/status stale.
|
||||||
Task { _ = await graph.coreRepository.refresh() }
|
Task { _ = await graph.coreRepository.refresh() }
|
||||||
case .background, .inactive:
|
case .background:
|
||||||
|
graph.visibility.setForeground(false)
|
||||||
|
// Hold the process open for iOS's grace window so an active
|
||||||
|
// transfer can finish and notify before suspension.
|
||||||
|
graph.backgroundActivity.didEnterBackground()
|
||||||
|
case .inactive:
|
||||||
graph.visibility.setForeground(false)
|
graph.visibility.setForeground(false)
|
||||||
@unknown default:
|
@unknown default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A pending approval is a blocking modal; close the sender's detail panel
|
// A pending approval is a blocking modal. Close the sender's detail panel
|
||||||
// (e.g. the Share/QR sheet) so the approval sheet isn't presented under it
|
// (e.g. the Share/QR sheet) first, then present the approval sheet — but on
|
||||||
// on macOS.
|
// macOS a sheet presented while another is still dismissing is silently
|
||||||
|
// dropped, so defer the presentation until that dismissal finishes.
|
||||||
.onChange(of: approvals.state.current?.id) { _, id in
|
.onChange(of: approvals.state.current?.id) { _, id in
|
||||||
if id != nil { sendModel.closeDetailPanel() }
|
guard id != nil else { showApproval = false; return }
|
||||||
|
let wasShowingSheet = sendModel.state.detailPanel != nil
|
||||||
|
sendModel.closeDetailPanel()
|
||||||
|
#if os(macOS)
|
||||||
|
if wasShowingSheet {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
|
||||||
|
if approvals.state.current != nil { showApproval = true }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showApproval = true
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
_ = wasShowingSheet
|
||||||
|
showApproval = true
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
// macOS keeps `scenePhase == .active` even when the app loses focus, so
|
// macOS keeps `scenePhase == .active` even when the app loses focus, so
|
||||||
@@ -183,6 +217,32 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A full-window cover with a centered spinner shown while the core is starting.
|
||||||
|
private struct CoreStartingOverlay: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
backgroundColor.ignoresSafeArea()
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ProgressView().controlSize(.large)
|
||||||
|
Text(String(localized: L10n.App.starting))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.transition(.opacity)
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel(Text(String(localized: L10n.App.starting)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var backgroundColor: Color {
|
||||||
|
#if os(iOS)
|
||||||
|
Color(uiColor: .systemBackground)
|
||||||
|
#else
|
||||||
|
Color(nsColor: .windowBackgroundColor)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import UIKit
|
import UIKit
|
||||||
#else
|
#else
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ private let mainWindowId = "main"
|
|||||||
@main
|
@main
|
||||||
struct VniDropApp: App {
|
struct VniDropApp: App {
|
||||||
@StateObject private var externalInvitations = ExternalInvitationController()
|
@StateObject private var externalInvitations = ExternalInvitationController()
|
||||||
|
#if DIRECT_DISTRIBUTION && os(macOS)
|
||||||
|
// Sparkle auto-updater, present only in the direct-download (.dmg) build.
|
||||||
|
@StateObject private var updater = SparkleUpdaterController()
|
||||||
|
#endif
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -18,6 +22,11 @@ struct VniDropApp: App {
|
|||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
.onOpenURL(perform: openInvitation)
|
.onOpenURL(perform: openInvitation)
|
||||||
}
|
}
|
||||||
|
#if DIRECT_DISTRIBUTION
|
||||||
|
.commands {
|
||||||
|
UpdatesCommands(controller: updater)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
#else
|
#else
|
||||||
WindowGroup(id: mainWindowId) {
|
WindowGroup(id: mainWindowId) {
|
||||||
RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations))
|
RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations))
|
||||||
|
|||||||
@@ -19,6 +19,101 @@ enum FolderAccessStatus {
|
|||||||
case unavailable
|
case unavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum RelayPreferenceMode: String, Codable, CaseIterable, Sendable {
|
||||||
|
case automatic
|
||||||
|
case strictCustom = "custom"
|
||||||
|
case customWithDirectFallback = "custom-with-direct-fallback"
|
||||||
|
case localOnly = "local-only"
|
||||||
|
|
||||||
|
var usesCustomRelayURLs: Bool {
|
||||||
|
self == .strictCustom || self == .customWithDirectFallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RelayConfiguration: Equatable, Codable, Sendable {
|
||||||
|
var mode: RelayPreferenceMode
|
||||||
|
var relayURLs: [String]
|
||||||
|
|
||||||
|
static let automatic = RelayConfiguration(mode: .automatic, relayURLs: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RelayConfigurationValidationError: Error, Equatable, Sendable {
|
||||||
|
case missingURL
|
||||||
|
case tooManyURLs
|
||||||
|
case httpsRequired(index: Int)
|
||||||
|
case invalidURL(index: Int)
|
||||||
|
case duplicateURL(index: Int)
|
||||||
|
|
||||||
|
var urlIndex: Int? {
|
||||||
|
switch self {
|
||||||
|
case .httpsRequired(let index), .invalidURL(let index), .duplicateURL(let index): return index
|
||||||
|
case .missingURL, .tooManyURLs: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RelayConfigurationValidator {
|
||||||
|
static let maximumRelayCount = 8
|
||||||
|
static let maximumRelayURLBytes = 2_048
|
||||||
|
|
||||||
|
static func validate(
|
||||||
|
mode: RelayPreferenceMode,
|
||||||
|
relayURLs: [String],
|
||||||
|
retainedRelayURLs: [String] = []
|
||||||
|
) throws -> RelayConfiguration {
|
||||||
|
guard mode.usesCustomRelayURLs else {
|
||||||
|
return RelayConfiguration(mode: mode, relayURLs: retainedRelayURLs)
|
||||||
|
}
|
||||||
|
|
||||||
|
let relayEntries = relayURLs.enumerated().compactMap { index, value -> (Int, String)? in
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmed.isEmpty ? nil : (index, trimmed)
|
||||||
|
}
|
||||||
|
guard !relayEntries.isEmpty else {
|
||||||
|
throw RelayConfigurationValidationError.missingURL
|
||||||
|
}
|
||||||
|
guard relayEntries.count <= maximumRelayCount else {
|
||||||
|
throw RelayConfigurationValidationError.tooManyURLs
|
||||||
|
}
|
||||||
|
|
||||||
|
var seen = Set<String>()
|
||||||
|
var normalizedURLs: [String] = []
|
||||||
|
for (index, relayURL) in relayEntries {
|
||||||
|
guard relayURL.lengthOfBytes(using: .utf8) <= maximumRelayURLBytes,
|
||||||
|
relayURL.rangeOfCharacter(from: .whitespacesAndNewlines.union(.controlCharacters)) == nil,
|
||||||
|
var components = URLComponents(string: relayURL) else {
|
||||||
|
throw RelayConfigurationValidationError.invalidURL(index: index)
|
||||||
|
}
|
||||||
|
guard components.scheme?.lowercased() == "https" else {
|
||||||
|
throw RelayConfigurationValidationError.httpsRequired(index: index)
|
||||||
|
}
|
||||||
|
guard
|
||||||
|
let host = components.host,
|
||||||
|
!host.isEmpty,
|
||||||
|
components.port.map({ (1...65_535).contains($0) }) ?? true,
|
||||||
|
components.user == nil,
|
||||||
|
components.password == nil,
|
||||||
|
components.query == nil,
|
||||||
|
components.fragment == nil,
|
||||||
|
components.path.isEmpty || components.path == "/"
|
||||||
|
else {
|
||||||
|
throw RelayConfigurationValidationError.invalidURL(index: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
components.scheme = "https"
|
||||||
|
components.host = host.lowercased()
|
||||||
|
if components.port == 443 { components.port = nil }
|
||||||
|
if components.path == "/" { components.path = "" }
|
||||||
|
guard let canonicalURL = components.string, seen.insert(canonicalURL).inserted else {
|
||||||
|
throw RelayConfigurationValidationError.duplicateURL(index: index)
|
||||||
|
}
|
||||||
|
normalizedURLs.append(canonicalURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RelayConfiguration(mode: mode, relayURLs: normalizedURLs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Persisted app preferences, ported from `preferences/AppPreferencesRepository.kt`.
|
/// Persisted app preferences, ported from `preferences/AppPreferencesRepository.kt`.
|
||||||
/// Backed by `UserDefaults` instead of DataStore; keys and semantics match.
|
/// Backed by `UserDefaults` instead of DataStore; keys and semantics match.
|
||||||
struct AppPreferences: Equatable {
|
struct AppPreferences: Equatable {
|
||||||
@@ -27,6 +122,7 @@ struct AppPreferences: Equatable {
|
|||||||
var themeMode: ThemeMode
|
var themeMode: ThemeMode
|
||||||
var diagnosticsEnabled: Bool
|
var diagnosticsEnabled: Bool
|
||||||
var diagnosticsInstallId: String
|
var diagnosticsInstallId: String
|
||||||
|
var relayConfiguration: RelayConfiguration
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AppPreferencesDefaults {
|
struct AppPreferencesDefaults {
|
||||||
@@ -51,6 +147,7 @@ final class AppPreferencesRepository: ObservableObject {
|
|||||||
static let themeMode = "theme_mode"
|
static let themeMode = "theme_mode"
|
||||||
static let diagnosticsEnabled = "diagnostics_enabled"
|
static let diagnosticsEnabled = "diagnostics_enabled"
|
||||||
static let diagnosticsInstallId = "diagnostics_install_id"
|
static let diagnosticsInstallId = "diagnostics_install_id"
|
||||||
|
static let relayConfiguration = "relay_configuration"
|
||||||
}
|
}
|
||||||
|
|
||||||
init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) {
|
init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) {
|
||||||
@@ -70,10 +167,24 @@ final class AppPreferencesRepository: ObservableObject {
|
|||||||
receiveFolder: folder,
|
receiveFolder: folder,
|
||||||
themeMode: themeMode,
|
themeMode: themeMode,
|
||||||
diagnosticsEnabled: diagnostics,
|
diagnosticsEnabled: diagnostics,
|
||||||
diagnosticsInstallId: installId
|
diagnosticsInstallId: installId,
|
||||||
|
relayConfiguration: resolveRelayConfiguration(defaults)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func resolveRelayConfiguration(_ defaults: UserDefaults) -> RelayConfiguration {
|
||||||
|
guard defaults.object(forKey: Key.relayConfiguration) != nil else { return .automatic }
|
||||||
|
guard
|
||||||
|
let data = defaults.data(forKey: Key.relayConfiguration),
|
||||||
|
let configuration = try? JSONDecoder().decode(RelayConfiguration.self, from: data)
|
||||||
|
else {
|
||||||
|
// A stored profile must never silently fall back to public relays. Strict
|
||||||
|
// custom with no URLs makes startup fail closed until Settings repairs it.
|
||||||
|
return RelayConfiguration(mode: .strictCustom, relayURLs: [])
|
||||||
|
}
|
||||||
|
return configuration
|
||||||
|
}
|
||||||
|
|
||||||
private static func resolveReceiveFolder(_ defaults: UserDefaults, fallback: ReceiveFolder) -> ReceiveFolder {
|
private static func resolveReceiveFolder(_ defaults: UserDefaults, fallback: ReceiveFolder) -> ReceiveFolder {
|
||||||
let kind = defaults.string(forKey: Key.receiveFolderKind)
|
let kind = defaults.string(forKey: Key.receiveFolderKind)
|
||||||
.flatMap(ReceiveFolderKind.init(rawValue:)) ?? fallback.kind
|
.flatMap(ReceiveFolderKind.init(rawValue:)) ?? fallback.kind
|
||||||
@@ -113,6 +224,12 @@ final class AppPreferencesRepository: ObservableObject {
|
|||||||
reload()
|
reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setRelayConfiguration(_ configuration: RelayConfiguration) {
|
||||||
|
guard let encoded = try? JSONEncoder().encode(configuration) else { return }
|
||||||
|
defaults.set(encoded, forKey: Key.relayConfiguration)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func ensureDiagnosticsInstallId() -> String {
|
func ensureDiagnosticsInstallId() -> String {
|
||||||
let existing = preferences.diagnosticsInstallId
|
let existing = preferences.diagnosticsInstallId
|
||||||
|
|||||||
77
apple/VniDrop/Core/BackgroundActivityController.swift
Normal file
77
apple/VniDrop/Core/BackgroundActivityController.swift
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Keeps the Rust core alive across the app moving to the background, within the
|
||||||
|
/// bounds Apple actually allows for a serverless P2P transfer app.
|
||||||
|
///
|
||||||
|
/// iOS suspends the whole process (freezing the core's network threads) shortly
|
||||||
|
/// after the app leaves the foreground. When a transfer or share is active we
|
||||||
|
/// take a `UIApplication` background-task assertion so iOS grants its finite
|
||||||
|
/// grace window — long enough for an in-flight transfer to finish streaming and
|
||||||
|
/// for its completion/failure notification to fire. There is no App-Store-legal
|
||||||
|
/// mechanism to keep serving or receiving *indefinitely* while backgrounded, and
|
||||||
|
/// `BGTaskScheduler` wake-ups run only opportunistically and cannot detect an
|
||||||
|
/// incoming peer connection, so they are deliberately not used here.
|
||||||
|
///
|
||||||
|
/// macOS does not suspend the process on focus loss, so this is a no-op there and
|
||||||
|
/// the core keeps running normally.
|
||||||
|
@MainActor
|
||||||
|
final class BackgroundActivityController {
|
||||||
|
private let repository: CoreRepository
|
||||||
|
|
||||||
|
init(repository: CoreRepository) {
|
||||||
|
self.repository = repository
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
private var assertionId: UIBackgroundTaskIdentifier = .invalid
|
||||||
|
private var idleCancellable: AnyCancellable?
|
||||||
|
|
||||||
|
/// The app moved to the background. Hold the process open while there is live
|
||||||
|
/// work; release as soon as it drains, on return to foreground, or when iOS
|
||||||
|
/// ends the grace window (whichever comes first).
|
||||||
|
func didEnterBackground() {
|
||||||
|
guard assertionId == .invalid, hasActiveWork else { return }
|
||||||
|
assertionId = UIApplication.shared.beginBackgroundTask(withName: "vnidrop.transfer") { [weak self] in
|
||||||
|
// Expiration handler: iOS is reclaiming the window; end cleanly to
|
||||||
|
// avoid the watchdog terminating the app.
|
||||||
|
self?.endAssertion()
|
||||||
|
}
|
||||||
|
// Release the assertion the moment work finishes instead of holding it for
|
||||||
|
// the full window (battery, and it lets the process suspend sooner). Events
|
||||||
|
// still deliver on the main actor while the window is open, so the core's
|
||||||
|
// active counts drop here when a transfer completes.
|
||||||
|
idleCancellable = repository.statePublisher
|
||||||
|
.map { ($0.status?.activeTransfers ?? 0) == 0 && ($0.status?.activeShares ?? 0) == 0 }
|
||||||
|
.removeDuplicates()
|
||||||
|
.sink { [weak self] idle in
|
||||||
|
if idle { self?.endAssertion() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The app returned to the foreground; the process is live again, so drop any
|
||||||
|
/// held assertion.
|
||||||
|
func didBecomeForeground() {
|
||||||
|
endAssertion()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasActiveWork: Bool {
|
||||||
|
let status = repository.state.status
|
||||||
|
return (status?.activeTransfers ?? 0) > 0 || (status?.activeShares ?? 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func endAssertion() {
|
||||||
|
idleCancellable?.cancel()
|
||||||
|
idleCancellable = nil
|
||||||
|
guard assertionId != .invalid else { return }
|
||||||
|
UIApplication.shared.endBackgroundTask(assertionId)
|
||||||
|
assertionId = .invalid
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
func didEnterBackground() {}
|
||||||
|
func didBecomeForeground() {}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ protocol CoreGateway: AnyObject {
|
|||||||
/// Coalesced change hints emitted by the event sink.
|
/// Coalesced change hints emitted by the event sink.
|
||||||
var signals: AnyPublisher<CoreSignal, Never> { get }
|
var signals: AnyPublisher<CoreSignal, Never> { get }
|
||||||
|
|
||||||
func initialize(appDataDir: String) async -> Result<Void, Error>
|
func initialize(appDataDir: String, networkConfiguration: RelayConfiguration) async -> Result<Void, Error>
|
||||||
func shutdown()
|
func shutdown()
|
||||||
func shareSources(
|
func shareSources(
|
||||||
_ sources: [ShareSource],
|
_ sources: [ShareSource],
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ enum ReceiverDeliveryStatus: Equatable, Sendable {
|
|||||||
case refused
|
case refused
|
||||||
case expired
|
case expired
|
||||||
case completed
|
case completed
|
||||||
|
case failed
|
||||||
case unknown
|
case unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,65 @@ import Foundation
|
|||||||
import Combine
|
import Combine
|
||||||
@preconcurrency import VnidropCore
|
@preconcurrency import VnidropCore
|
||||||
|
|
||||||
|
enum CoreNetworkLifecycleError: Error, Equatable, LocalizedError, Sendable {
|
||||||
|
case transitionInProgress
|
||||||
|
case activeNetworkWork
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .transitionInProgress: return "A network restart is already in progress."
|
||||||
|
case .activeNetworkWork: return "Stop active transfers and shares before restarting the network."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CoreNetworkLifecycle {
|
||||||
|
nonisolated static func requireIdle(activeTransfers: UInt64, activeShares: UInt64) throws {
|
||||||
|
guard activeTransfers == 0, activeShares == 0 else {
|
||||||
|
throw CoreNetworkLifecycleError.activeNetworkWork
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol CoreBindingFactory: Sendable {
|
||||||
|
func initialize(
|
||||||
|
appDataDir: String,
|
||||||
|
eventSink: CoreEventSink,
|
||||||
|
networkConfiguration: RelayConfiguration
|
||||||
|
) throws -> VnidropCore
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NativeCoreBindingFactory: CoreBindingFactory {
|
||||||
|
func initialize(
|
||||||
|
appDataDir: String,
|
||||||
|
eventSink: CoreEventSink,
|
||||||
|
networkConfiguration: RelayConfiguration
|
||||||
|
) throws -> VnidropCore {
|
||||||
|
let nativeConfiguration: CoreNetworkConfig
|
||||||
|
switch networkConfiguration.mode {
|
||||||
|
case .automatic:
|
||||||
|
nativeConfiguration = defaultCoreNetworkConfig()
|
||||||
|
case .strictCustom:
|
||||||
|
nativeConfiguration = CoreNetworkConfig(
|
||||||
|
mode: .strictCustom,
|
||||||
|
relayUrls: networkConfiguration.relayURLs
|
||||||
|
)
|
||||||
|
case .customWithDirectFallback:
|
||||||
|
nativeConfiguration = CoreNetworkConfig(
|
||||||
|
mode: .customWithDirectFallback,
|
||||||
|
relayUrls: networkConfiguration.relayURLs
|
||||||
|
)
|
||||||
|
case .localOnly:
|
||||||
|
nativeConfiguration = CoreNetworkConfig(mode: .localOnly, relayUrls: [])
|
||||||
|
}
|
||||||
|
return try VnidropCore.initializeWithNetworkConfig(
|
||||||
|
appDataDir: appDataDir,
|
||||||
|
eventSink: eventSink,
|
||||||
|
networkConfig: nativeConfiguration
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Swift port of `core/CoreRepository.kt`. Owns the `VnidropCore` handle, maps the
|
/// Swift port of `core/CoreRepository.kt`. Owns the `VnidropCore` handle, maps the
|
||||||
/// generated UniFFI records into app domain models, publishes an observable
|
/// generated UniFFI records into app domain models, publishes an observable
|
||||||
/// `CoreState`, and emits coalesced `CoreSignal`s from the event sink.
|
/// `CoreState`, and emits coalesced `CoreSignal`s from the event sink.
|
||||||
@@ -17,28 +76,65 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
|||||||
/// Coalesced change hints; subscribe to react to approval/history/transfer changes.
|
/// Coalesced change hints; subscribe to react to approval/history/transfer changes.
|
||||||
var signals: AnyPublisher<CoreSignal, Never> { signalsSubject.eraseToAnyPublisher() }
|
var signals: AnyPublisher<CoreSignal, Never> { signalsSubject.eraseToAnyPublisher() }
|
||||||
|
|
||||||
// Set on the main actor (initialize/shutdown) but read from `queue` inside
|
// Initialization swaps happen on `queue`; shutdown and snapshot reads may also
|
||||||
// `runCore`; the underlying core is internally synchronized, so this crossing
|
// access the handle from the main actor. The underlying core is internally
|
||||||
// is safe. `nonisolated(unsafe)` documents that contract for Swift 6.
|
// synchronized, and `nonisolated(unsafe)` documents that crossing for Swift 6.
|
||||||
private nonisolated(unsafe) var core: VnidropCore?
|
private nonisolated(unsafe) var core: VnidropCore?
|
||||||
|
// Core calls run through `dispatcher` (see runCore/runInterrupt); the factory
|
||||||
|
// and transition flag drive relay-aware (re)initialization.
|
||||||
private let dispatcher = CoreDispatcher()
|
private let dispatcher = CoreDispatcher()
|
||||||
|
private let coreFactory: any CoreBindingFactory
|
||||||
|
private var isNetworkTransitionInProgress = false
|
||||||
private lazy var sink = RepositoryEventSink { [weak self] event in
|
private lazy var sink = RepositoryEventSink { [weak self] event in
|
||||||
Task { @MainActor in self?.handle(event: event) }
|
Task { @MainActor in self?.handle(event: event) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static let maxEvents = 200
|
private nonisolated static let maxEvents = 200
|
||||||
|
|
||||||
|
init(coreFactory: any CoreBindingFactory = NativeCoreBindingFactory()) {
|
||||||
|
self.coreFactory = coreFactory
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
func initialize(appDataDir: String) async -> Result<Void, Error> {
|
func initialize(
|
||||||
await runCore { [sink] in
|
appDataDir: String,
|
||||||
self.core?.shutdown()
|
networkConfiguration: RelayConfiguration
|
||||||
let created = try VnidropCore.initialize(appDataDir: appDataDir, eventSink: sink)
|
) async -> Result<Void, Error> {
|
||||||
return created
|
guard !isNetworkTransitionInProgress else {
|
||||||
}.map { created in
|
return .failure(CoreNetworkLifecycleError.transitionInProgress)
|
||||||
|
}
|
||||||
|
isNetworkTransitionInProgress = true
|
||||||
|
defer { isNetworkTransitionInProgress = false }
|
||||||
|
|
||||||
|
let result = await runCore { [sink] in
|
||||||
|
if let existing = self.core {
|
||||||
|
let status = existing.status()
|
||||||
|
try CoreNetworkLifecycle.requireIdle(
|
||||||
|
activeTransfers: status.activeTransfers,
|
||||||
|
activeShares: status.activeShares
|
||||||
|
)
|
||||||
|
existing.shutdown()
|
||||||
|
self.core = nil
|
||||||
|
}
|
||||||
|
let created = try self.coreFactory.initialize(
|
||||||
|
appDataDir: appDataDir,
|
||||||
|
eventSink: sink,
|
||||||
|
networkConfiguration: networkConfiguration
|
||||||
|
)
|
||||||
self.core = created
|
self.core = created
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
self.refreshSnapshot()
|
self.refreshSnapshot()
|
||||||
self.state.isInitialized = true
|
self.state.isInitialized = true
|
||||||
|
return .success(())
|
||||||
|
case .failure(let error):
|
||||||
|
if error as? CoreNetworkLifecycleError != .activeNetworkWork {
|
||||||
|
self.state = CoreState()
|
||||||
|
}
|
||||||
|
return .failure(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,8 +152,11 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
|||||||
senderName: String,
|
senderName: String,
|
||||||
accessPolicy: ShareAccessPolicy
|
accessPolicy: ShareAccessPolicy
|
||||||
) async -> Result<Share, Error> {
|
) async -> Result<Share, Error> {
|
||||||
|
guard !isNetworkTransitionInProgress else {
|
||||||
|
return .failure(CoreNetworkLifecycleError.transitionInProgress)
|
||||||
|
}
|
||||||
guard !sources.isEmpty else {
|
guard !sources.isEmpty else {
|
||||||
return .failure(InvitationError.message("Select at least one file to share"))
|
return .failure(InvitationError.shareEmpty)
|
||||||
}
|
}
|
||||||
return await runCore {
|
return await runCore {
|
||||||
let result = try self.requireCore().shareFiles(
|
let result = try self.requireCore().shareFiles(
|
||||||
@@ -89,7 +188,10 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func receive(ticket: String, outputDir: String, receiverName: String) async -> Result<Void, Error> {
|
func receive(ticket: String, outputDir: String, receiverName: String) async -> Result<Void, Error> {
|
||||||
await runCore {
|
guard !isNetworkTransitionInProgress else {
|
||||||
|
return .failure(CoreNetworkLifecycleError.transitionInProgress)
|
||||||
|
}
|
||||||
|
return await runCore {
|
||||||
try self.requireCore().receive(
|
try self.requireCore().receive(
|
||||||
ticket: ticket,
|
ticket: ticket,
|
||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
@@ -105,7 +207,10 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
|||||||
outputDirectoryUrl: String,
|
outputDirectoryUrl: String,
|
||||||
receiverName: String
|
receiverName: String
|
||||||
) async -> Result<Void, Error> {
|
) async -> Result<Void, Error> {
|
||||||
await runCore {
|
guard !isNetworkTransitionInProgress else {
|
||||||
|
return .failure(CoreNetworkLifecycleError.transitionInProgress)
|
||||||
|
}
|
||||||
|
return await runCore {
|
||||||
try withSecurityScopedAccess(pathOrUrl: outputDirectoryUrl) {
|
try withSecurityScopedAccess(pathOrUrl: outputDirectoryUrl) {
|
||||||
try self.requireCore().receive(
|
try self.requireCore().receive(
|
||||||
ticket: ticket,
|
ticket: ticket,
|
||||||
@@ -248,7 +353,7 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
|||||||
|
|
||||||
private nonisolated func requireCore() throws -> VnidropCore {
|
private nonisolated func requireCore() throws -> VnidropCore {
|
||||||
guard let core = self.core else {
|
guard let core = self.core else {
|
||||||
throw InvitationError.message("Initialize the core first.")
|
throw InvitationError.coreNotInitialized
|
||||||
}
|
}
|
||||||
return core
|
return core
|
||||||
}
|
}
|
||||||
@@ -409,6 +514,7 @@ private extension ReceiverRequest {
|
|||||||
case "refused": return .refused
|
case "refused": return .refused
|
||||||
case "expired": return .expired
|
case "expired": return .expired
|
||||||
case "completed": return .completed
|
case "completed": return .completed
|
||||||
|
case "failed": return .failed
|
||||||
default: return .unknown
|
default: return .unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,23 +23,42 @@ final class ExternalInvitationController: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func reportOpenFailure(message: String) {
|
func reportOpenFailure(message: String) {
|
||||||
continuation?.yield(.failure(InvitationError.message(message)))
|
continuation?.yield(.failure(InvitationError.raw(message)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Semantic, UI-agnostic invitation/transfer failures. Cases carry no display
|
||||||
|
/// text: `Error.toUiText()` (UI layer) maps each case to a localized `L10n` key,
|
||||||
|
/// so there are no free-form English strings to keep in sync or substring-match.
|
||||||
|
/// `.raw` is the escape hatch for genuinely dynamic system/core messages (e.g. a
|
||||||
|
/// `CoreNFC` `localizedDescription` or a picker's failure reason), never shown
|
||||||
|
/// verbatim — it is still routed through `reasonHints`.
|
||||||
enum InvitationError: LocalizedError {
|
enum InvitationError: LocalizedError {
|
||||||
case empty
|
case empty
|
||||||
case tooLarge
|
case tooLarge
|
||||||
case invalidEncoding
|
case invalidEncoding
|
||||||
case message(String)
|
case shareEmpty
|
||||||
|
case cancelled
|
||||||
|
case coreNotInitialized
|
||||||
|
case unsupportedOperation
|
||||||
|
case noWindowAvailable
|
||||||
|
case viewControllerUnavailable
|
||||||
|
case filesystemUnavailable
|
||||||
|
case invalidInvitationURL
|
||||||
|
case nfcUnavailable
|
||||||
|
case nfcFailed
|
||||||
|
case cameraUnavailable
|
||||||
|
case qrUnavailable
|
||||||
|
case bugReportingUnavailable
|
||||||
|
case selectionFailed
|
||||||
|
case deleteRecordsFailed
|
||||||
|
case raw(String)
|
||||||
|
|
||||||
|
/// Developer/log-facing only — never surfaced to users. Derived from the case
|
||||||
|
/// so there are no hand-written English blobs; `.raw` passes its payload through.
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
if case .raw(let reason) = self { return reason }
|
||||||
case .empty: return "The invitation is empty"
|
return String(describing: self)
|
||||||
case .tooLarge: return "The invitation is too large"
|
|
||||||
case .invalidEncoding: return "The invitation is not valid text"
|
|
||||||
case .message(let m): return m
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ struct PickedShareFile: Equatable, Identifiable, Sendable {
|
|||||||
var isTemporaryCopy: Bool = false
|
var isTemporaryCopy: Bool = false
|
||||||
/// When true, `value` is a directory (path or security-scoped folder URL).
|
/// When true, `value` is a directory (path or security-scoped folder URL).
|
||||||
var isDirectory: Bool = false
|
var isDirectory: Bool = false
|
||||||
|
/// macOS sandbox: a security-scoped bookmark captured at pick time so access to
|
||||||
|
/// `value` can be re-acquired when the core imports the file (the picker's own
|
||||||
|
/// scope ends immediately). Nil on iOS (which copies into the container instead).
|
||||||
|
var securityScopeBookmark: Data? = nil
|
||||||
|
|
||||||
var id: String { value }
|
var id: String { value }
|
||||||
}
|
}
|
||||||
@@ -48,7 +52,7 @@ extension FileSystemService {
|
|||||||
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
|
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
|
||||||
|
|
||||||
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
|
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
|
||||||
.failure(InvitationError.message("Revealing the receive folder is not supported"))
|
.failure(InvitationError.unsupportedOperation)
|
||||||
}
|
}
|
||||||
|
|
||||||
func discardPickedFiles(_ files: [PickedShareFile]) async {}
|
func discardPickedFiles(_ files: [PickedShareFile]) async {}
|
||||||
|
|||||||
@@ -26,6 +26,27 @@ private final class NotificationPresenter: NSObject, UNUserNotificationCenterDel
|
|||||||
) async -> UNNotificationPresentationOptions {
|
) async -> UNNotificationPresentationOptions {
|
||||||
[.banner, .sound, .list]
|
[.banner, .sound, .list]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle a notification tap inside the running instance and bring the existing
|
||||||
|
/// window forward, rather than letting the default launch behavior surface (which
|
||||||
|
/// on macOS can spin up a second process). The approval/transfer UI is driven by
|
||||||
|
/// core state, so activating the window is enough to reveal a pending approval.
|
||||||
|
func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
didReceive response: UNNotificationResponse
|
||||||
|
) async {
|
||||||
|
#if os(macOS)
|
||||||
|
await MainActor.run {
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
// Reopen/focus the single main window (activation triggers SwiftUI's
|
||||||
|
// reopen handling when it was closed).
|
||||||
|
for window in NSApp.windows where window.canBecomeMain {
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Local notification service backed by `UNUserNotificationCenter`.
|
/// Local notification service backed by `UNUserNotificationCenter`.
|
||||||
|
|||||||
@@ -82,7 +82,9 @@ func progressForReceiver(
|
|||||||
labelKey: L10n.Progress.interrupted, progress: nil, detail: nil
|
labelKey: L10n.Progress.interrupted, progress: nil, detail: nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if latestKind == .completed && !transferEvents.contains(where: { $0.eventKind == .progress || $0.eventKind == .started }) {
|
// Events are newest-first, so a completed latest event is terminal even when
|
||||||
|
// progress/started events precede it — it must show as Completed, not Sending.
|
||||||
|
if latestKind == .completed {
|
||||||
return TransferProgress(
|
return TransferProgress(
|
||||||
transferId: transferId, phase: .transfer, kind: .completed,
|
transferId: transferId, phase: .transfer, kind: .completed,
|
||||||
labelKey: L10n.Progress.completed, progress: 1, detail: nil
|
labelKey: L10n.Progress.completed, progress: 1, detail: nil
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ final class AppModel: ObservableObject {
|
|||||||
AppLogger.info("lifecycle", "app started", ["platform": environment.name])
|
AppLogger.info("lifecycle", "app started", ["platform": environment.name])
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
let result = await repository.initialize(appDataDir: environment.defaultCoreDataDir)
|
let result = await repository.initialize(
|
||||||
|
appDataDir: environment.defaultCoreDataDir,
|
||||||
|
networkConfiguration: preferences.preferences.relayConfiguration
|
||||||
|
)
|
||||||
if case .failure(let error) = result { messages.error(error) }
|
if case .failure(let error) = result { messages.error(error) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,17 @@ import SFSafeSymbols
|
|||||||
/// be swiped away. The endpoint id is the trusted identity; display names are
|
/// be swiped away. The endpoint id is the trusted identity; display names are
|
||||||
/// peer-provided.
|
/// peer-provided.
|
||||||
struct ApprovalModalHost: View {
|
struct ApprovalModalHost: View {
|
||||||
|
/// Driven by the host so presentation can be deferred until any competing sheet
|
||||||
|
/// (the Share/QR drawer) has finished dismissing — macOS silently drops a sheet
|
||||||
|
/// presented while another is still animating out.
|
||||||
|
@Binding var isPresented: Bool
|
||||||
let state: ApprovalState
|
let state: ApprovalState
|
||||||
let onAccept: (String) -> Void
|
let onAccept: (String) -> Void
|
||||||
let onRefuse: (String) -> Void
|
let onRefuse: (String) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Color.clear
|
Color.clear
|
||||||
.sheet(isPresented: .constant(state.current != nil)) {
|
.sheet(isPresented: $isPresented) {
|
||||||
if let request = state.current {
|
if let request = state.current {
|
||||||
ApprovalSheet(state: state, request: request, onAccept: onAccept, onRefuse: onRefuse)
|
ApprovalSheet(state: state, request: request, onAccept: onAccept, onRefuse: onRefuse)
|
||||||
.interactiveDismissDisabled(true)
|
.interactiveDismissDisabled(true)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ enum TransferNotificationKind: Equatable {
|
|||||||
case receiveCompleted // An incoming transfer finished downloading.
|
case receiveCompleted // An incoming transfer finished downloading.
|
||||||
case receiveFailed // An incoming transfer failed.
|
case receiveFailed // An incoming transfer failed.
|
||||||
case receiverCompleted // A receiver finished downloading your shared transfer.
|
case receiverCompleted // A receiver finished downloading your shared transfer.
|
||||||
|
case receiverFailed // A receiver's download of your shared transfer failed.
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A notification resolved from core state but not yet published. `transferName`
|
/// A notification resolved from core state but not yet published. `transferName`
|
||||||
@@ -39,11 +40,17 @@ func plannedTransferNotifications(_ transfers: [Transfer], published: Set<String
|
|||||||
/// transfer, excluding already-published ids.
|
/// transfer, excluding already-published ids.
|
||||||
func plannedReceiverNotifications(_ requests: [ReceiverRequestModel], published: Set<String>) -> [PlannedNotification] {
|
func plannedReceiverNotifications(_ requests: [ReceiverRequestModel], published: Set<String>) -> [PlannedNotification] {
|
||||||
requests.compactMap { request in
|
requests.compactMap { request in
|
||||||
guard request.status == .completed else { return nil }
|
let kind: TransferNotificationKind
|
||||||
let id = "receiver-completed-\(request.id)"
|
let idPrefix: String
|
||||||
|
switch request.status {
|
||||||
|
case .completed: kind = .receiverCompleted; idPrefix = "receiver-completed"
|
||||||
|
case .failed: kind = .receiverFailed; idPrefix = "receiver-failed"
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
let id = "\(idPrefix)-\(request.id)"
|
||||||
guard !published.contains(id) else { return nil }
|
guard !published.contains(id) else { return nil }
|
||||||
return PlannedNotification(
|
return PlannedNotification(
|
||||||
id: id, kind: .receiverCompleted,
|
id: id, kind: kind,
|
||||||
transferName: request.transferName,
|
transferName: request.transferName,
|
||||||
receiver: request.receiverName ?? request.receiverDeviceName
|
receiver: request.receiverName ?? request.receiverDeviceName
|
||||||
)
|
)
|
||||||
@@ -56,6 +63,7 @@ private func transferNotificationId(_ kind: TransferNotificationKind, transferId
|
|||||||
case .receiveCompleted: return "receive-completed-\(transferId)"
|
case .receiveCompleted: return "receive-completed-\(transferId)"
|
||||||
case .receiveFailed: return "receive-failed-\(transferId)"
|
case .receiveFailed: return "receive-failed-\(transferId)"
|
||||||
case .receiverCompleted: return "receiver-completed-\(transferId)"
|
case .receiverCompleted: return "receiver-completed-\(transferId)"
|
||||||
|
case .receiverFailed: return "receiver-failed-\(transferId)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +184,12 @@ final class TransferNotificationCoordinator: ObservableObject {
|
|||||||
id: plan.id,
|
id: plan.id,
|
||||||
title: String(localized: L10n.Notifications.receiverCompletedTitle),
|
title: String(localized: L10n.Notifications.receiverCompletedTitle),
|
||||||
body: L10n.Notifications.receiverCompletedBody(receiver: receiver, transferName: name))
|
body: L10n.Notifications.receiverCompletedBody(receiver: receiver, transferName: name))
|
||||||
|
case .receiverFailed:
|
||||||
|
let receiver = plan.receiver ?? String(localized: L10n.Approval.nearbyDevice)
|
||||||
|
notification = LocalNotification(
|
||||||
|
id: plan.id,
|
||||||
|
title: String(localized: L10n.Notifications.receiverFailedTitle),
|
||||||
|
body: L10n.Notifications.receiverFailedBody(receiver: receiver, transferName: name))
|
||||||
}
|
}
|
||||||
if case .failure(let error) = await notifications.publish(notification) {
|
if case .failure(let error) = await notifications.publish(notification) {
|
||||||
messages.error(error)
|
messages.error(error)
|
||||||
|
|||||||
@@ -79,6 +79,15 @@ struct ReceiveScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.contextMenu {
|
||||||
|
if transfer.status.isTerminalReceiveHistory {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
model.requestDeleteHistoryItem(transfer.transferId)
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text(String(localized: L10n.Receive.historyTitle))
|
Text(String(localized: L10n.Receive.historyTitle))
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ final class SendModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func onFilePickFailed(_ reason: String) {
|
func onFilePickFailed(_ reason: String) {
|
||||||
messages.error(InvitationError.message(reason.isEmpty ? "selection failed" : reason))
|
messages.error(reason.isEmpty ? InvitationError.selectionFailed : InvitationError.raw(reason))
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearSelectedSource() {
|
func clearSelectedSource() {
|
||||||
@@ -228,6 +228,31 @@ final class SendModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deletes a transfer by id, independent of the detail selection — used by the
|
||||||
|
/// list context menu so it can act inline without navigating into the detail.
|
||||||
|
func deleteTransfer(id: UInt64) {
|
||||||
|
if state.isDeleting { return }
|
||||||
|
state.isDeleting = true
|
||||||
|
Task {
|
||||||
|
let result = await repository.delete(transferId: id)
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
filePreviewRepository.remove(transferId: id)
|
||||||
|
if state.selectedTransferId == id {
|
||||||
|
state.selectedTransferId = nil
|
||||||
|
state.detailPanel = nil
|
||||||
|
state.receiverHistory = []
|
||||||
|
}
|
||||||
|
state.isDeleting = false
|
||||||
|
_ = await repository.refresh()
|
||||||
|
messages.tryShow(UiMessage(text: .resource(L10n.Transfer.deleted), tone: .success))
|
||||||
|
case .failure(let error):
|
||||||
|
state.isDeleting = false
|
||||||
|
messages.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Cancels/refuses a single receiver by responding to its request negatively.
|
/// Cancels/refuses a single receiver by responding to its request negatively.
|
||||||
/// Uses the core's `respondReceiverRequest` (no backend change); applies to
|
/// Uses the core's `respondReceiverRequest` (no backend change); applies to
|
||||||
/// receivers that are still pending or accepted.
|
/// receivers that are still pending or accepted.
|
||||||
@@ -300,6 +325,13 @@ final class SendModel: ObservableObject {
|
|||||||
state.transferName = ""
|
state.transferName = ""
|
||||||
state.accessPolicy = .requireApproval
|
state.accessPolicy = .requireApproval
|
||||||
state.isSharing = false
|
state.isSharing = false
|
||||||
|
// Jump straight to the new transfer's share panel (QR + delivery) rather
|
||||||
|
// than dropping the user on the list to drill in manually. Refresh first
|
||||||
|
// so the transfer exists in state before it's selected.
|
||||||
|
_ = await repository.refresh()
|
||||||
|
state.selectedTransferId = share.transferId
|
||||||
|
state.detailPanel = .share
|
||||||
|
refreshReceivers(share.transferId)
|
||||||
messages.show(UiMessage(text: .resource(L10n.Send.transferCreated), tone: .success))
|
messages.show(UiMessage(text: .resource(L10n.Send.transferCreated), tone: .success))
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
state.isSharing = false
|
state.isSharing = false
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ struct SendScreen: View {
|
|||||||
@ObservedObject var model: SendModel
|
@ObservedObject var model: SendModel
|
||||||
let windowClass: WindowClass
|
let windowClass: WindowClass
|
||||||
|
|
||||||
|
/// Transfer whose share panel is presented inline from the list context menu.
|
||||||
|
@State private var shareTarget: Transfer?
|
||||||
|
/// Transfer pending an inline (list-level) delete confirmation.
|
||||||
|
@State private var deleteTarget: Transfer?
|
||||||
|
|
||||||
private var outgoing: [Transfer] {
|
private var outgoing: [Transfer] {
|
||||||
model.coreState.transfers.filter { $0.direction == .send }
|
model.coreState.transfers.filter { $0.direction == .send }
|
||||||
}
|
}
|
||||||
@@ -41,6 +46,18 @@ struct SendScreen: View {
|
|||||||
detailView(for: transfer)
|
detailView(for: transfer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Attached inside the NavigationStack (a different sheet host than the
|
||||||
|
// composer drawer on the outer body, so the two don't clash). Opens the
|
||||||
|
// share panel over the list without navigating into the transfer detail.
|
||||||
|
.adaptiveDrawer(
|
||||||
|
isPresented: Binding(get: { shareTarget != nil }, set: { if !$0 { shareTarget = nil } }),
|
||||||
|
windowClass: windowClass,
|
||||||
|
onDismiss: { shareTarget = nil }
|
||||||
|
) {
|
||||||
|
if let shareTarget {
|
||||||
|
TransferSharePanel(model: model, transfer: shareTarget)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.adaptiveDrawer(
|
.adaptiveDrawer(
|
||||||
isPresented: Binding(get: { model.state.isComposerOpen }, set: { _ in }),
|
isPresented: Binding(get: { model.state.isComposerOpen }, set: { _ in }),
|
||||||
@@ -49,8 +66,24 @@ struct SendScreen: View {
|
|||||||
) {
|
) {
|
||||||
TransferComposer(model: model, windowClass: windowClass)
|
TransferComposer(model: model, windowClass: windowClass)
|
||||||
}
|
}
|
||||||
|
.alert(
|
||||||
|
Text(String(localized: L10n.Transfer.deleteTitle)),
|
||||||
|
isPresented: Binding(get: { deleteTarget != nil }, set: { if !$0 { deleteTarget = nil } })
|
||||||
|
) {
|
||||||
|
Button(String(localized: L10n.Button.cancel), role: .cancel) { deleteTarget = nil }
|
||||||
|
Button(String(localized: L10n.Button.deleteTransfer), role: .destructive) {
|
||||||
|
if let target = deleteTarget { model.deleteTransfer(id: target.transferId) }
|
||||||
|
deleteTarget = nil
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
if let target = deleteTarget {
|
||||||
|
Text(L10n.Transfer.deleteDescription(
|
||||||
|
transferName: target.transferName ?? String(localized: L10n.Send.newTransferTitle)))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// The pushed transfer details view, with its detail-panel sheet and delete
|
/// The pushed transfer details view, with its detail-panel sheet and delete
|
||||||
/// alert attached here so they present from the detail's own context (presenting
|
/// alert attached here so they present from the detail's own context (presenting
|
||||||
/// modals from the parent stack while a detail is pushed is unreliable on macOS).
|
/// modals from the parent stack while a detail is pushed is unreliable on macOS).
|
||||||
@@ -91,6 +124,28 @@ struct SendScreen: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.contextMenu {
|
||||||
|
if transfer.ticket != nil {
|
||||||
|
Button {
|
||||||
|
shareTarget = transfer
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if transfer.status == .sharing {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
model.stopSharing(transferId: transfer.transferId)
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: L10n.Send.stopSharing), systemSymbol: .stopCircle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
Button(role: .destructive) {
|
||||||
|
deleteTarget = transfer
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text(String(localized: L10n.Send.transfersTitle))
|
Text(String(localized: L10n.Send.transfersTitle))
|
||||||
|
|||||||
@@ -75,29 +75,41 @@ struct TransferComposer: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var actions: some View {
|
private var actions: some View {
|
||||||
let shareTitle = state.isSharing
|
let shareTitle = state.isSharing
|
||||||
? String(localized: L10n.Button.sharingFile) : String(localized: L10n.Button.shareFile)
|
? String(localized: L10n.Button.sharingFile) : String(localized: L10n.Button.shareFile)
|
||||||
let shareButton = PrimaryButton(
|
return VStack(spacing: 10) {
|
||||||
title: shareTitle, action: model.createShare,
|
PrimaryButton(
|
||||||
enabled: state.canCreateShare(coreInitialized: model.coreState.isInitialized)
|
title: shareTitle, action: model.createShare,
|
||||||
)
|
enabled: state.canCreateShare(coreInitialized: model.coreState.isInitialized)
|
||||||
if windowClass == .phone {
|
)
|
||||||
VStack(spacing: 8) {
|
// Secondary source actions as an even row of bordered buttons rather than
|
||||||
shareButton
|
// bare text links, so they read as controls and align with the primary.
|
||||||
QuietButton(title: String(localized: L10n.Button.changeFiles), action: model.selectFile, enabled: !state.isSharing)
|
HStack(spacing: 10) {
|
||||||
QuietButton(title: String(localized: L10n.Button.chooseFolder), action: model.selectFolder, enabled: !state.isSharing)
|
sourceButton(title: L10n.Button.changeFiles, symbol: .docBadgeArrowUp, action: model.selectFile)
|
||||||
}
|
sourceButton(title: L10n.Button.chooseFolder, symbol: .folder, action: model.selectFolder)
|
||||||
} else {
|
if windowClass != .phone {
|
||||||
HStack(spacing: 8) {
|
sourceButton(title: L10n.Button.clear, symbol: .xmark, action: model.clearSelectedSource)
|
||||||
shareButton.fixedSize()
|
}
|
||||||
QuietButton(title: String(localized: L10n.Button.changeFiles), action: model.selectFile, enabled: !state.isSharing)
|
|
||||||
QuietButton(title: String(localized: L10n.Button.chooseFolder), action: model.selectFolder, enabled: !state.isSharing)
|
|
||||||
QuietButton(title: String(localized: L10n.Button.clear), action: model.clearSelectedSource, enabled: !state.isSharing)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func sourceButton(
|
||||||
|
title: String.LocalizationValue, symbol: SFSymbol, action: @escaping () -> Void
|
||||||
|
) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Label(String(localized: title), systemSymbol: symbol)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.85)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(minHeight: 20)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.large)
|
||||||
|
.tint(.secondary)
|
||||||
|
.disabled(state.isSharing)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SelectedFileCard: View {
|
private struct SelectedFileCard: View {
|
||||||
|
|||||||
@@ -44,22 +44,19 @@ struct TransferDetailsView: View {
|
|||||||
count: pendingReceivers + completedReceivers,
|
count: pendingReceivers + completedReceivers,
|
||||||
onTap: model.openReceivers
|
onTap: model.openReceivers
|
||||||
)
|
)
|
||||||
DetailDestination(
|
|
||||||
title: String(localized: L10n.Transfer.shareTitle),
|
|
||||||
description: String(localized: L10n.Transfer.shareDescription),
|
|
||||||
count: 0,
|
|
||||||
onTap: model.openShare
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if isActiveShare {
|
Section {
|
||||||
Section {
|
if isActiveShare {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
showStopConfirmation = true
|
showStopConfirmation = true
|
||||||
} label: {
|
} label: {
|
||||||
Label(String(localized: L10n.Send.stopSharing), systemSymbol: .stopCircle)
|
Label(String(localized: L10n.Send.stopSharing), systemSymbol: .stopCircle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Button(role: .destructive, action: model.requestDeleteTransfer) {
|
||||||
|
Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
@@ -69,9 +66,10 @@ struct TransferDetailsView: View {
|
|||||||
#endif
|
#endif
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
Button(role: .destructive, action: model.requestDeleteTransfer) {
|
Button(action: model.openShare) {
|
||||||
Image(systemSymbol: .trash)
|
Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp)
|
||||||
}
|
}
|
||||||
|
.help(String(localized: L10n.Transfer.shareTitle))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
@@ -114,7 +112,7 @@ private struct DetailDestination: View {
|
|||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
Text("\(count)")
|
Text(verbatim: "\(count)")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -238,6 +236,7 @@ private struct ReceiverRow: View {
|
|||||||
let name = receiver.receiverName ?? receiver.receiverDeviceName ?? String(localized: L10n.Transfer.nearbyDevice)
|
let name = receiver.receiverName ?? receiver.receiverDeviceName ?? String(localized: L10n.Transfer.nearbyDevice)
|
||||||
let showLive = sendProgress != nil && receiver.status != .completed
|
let showLive = sendProgress != nil && receiver.status != .completed
|
||||||
&& receiver.status != .refused && receiver.status != .expired
|
&& receiver.status != .refused && receiver.status != .expired
|
||||||
|
&& receiver.status != .failed
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text(name).font(VniType.bodyLarge).lineLimit(1)
|
Text(name).font(VniType.bodyLarge).lineLimit(1)
|
||||||
@@ -252,7 +251,8 @@ private struct ReceiverRow: View {
|
|||||||
.foregroundStyle(receiver.status.statusColor(colors))
|
.foregroundStyle(receiver.status.statusColor(colors))
|
||||||
}
|
}
|
||||||
if let reason = receiver.reason, !reason.isEmpty {
|
if let reason = receiver.reason, !reason.isEmpty {
|
||||||
Text(reason).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
Text(receiverReasonUiText(reason).resolved())
|
||||||
|
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@@ -279,24 +279,39 @@ struct TransferSharePanel: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
PanelContainer(title: String(localized: L10n.Transfer.shareTitle)) {
|
PanelContainer(title: String(localized: L10n.Transfer.shareTitle)) {
|
||||||
if let ticket = transfer.ticket {
|
switch transfer.invitationPresentation {
|
||||||
qrCard(ticket: ticket)
|
case .ready(let ticket):
|
||||||
Text(String(localized: L10n.Transfer.scanQr))
|
let qrImage = QRCode.generate(from: ticket)
|
||||||
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
qrCard(image: qrImage)
|
||||||
.frame(maxWidth: .infinity)
|
if qrImage != nil {
|
||||||
|
Text(String(localized: L10n.Transfer.scanQr))
|
||||||
|
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
ShareActionsView(model: model, transfer: transfer, ticket: ticket)
|
ShareActionsView(model: model, transfer: transfer, ticket: ticket)
|
||||||
} else {
|
case .preparing:
|
||||||
Text(String(localized: L10n.Transfer.eventPreparing)).foregroundStyle(colors.foregroundLighter)
|
Text(String(localized: L10n.Transfer.eventPreparing)).foregroundStyle(colors.foregroundLighter)
|
||||||
|
case .unavailable:
|
||||||
|
Text(String(localized: transfer.status == .failed ? L10n.Transfer.eventFailed : L10n.Transfer.eventStopped))
|
||||||
|
.foregroundStyle(colors.foregroundLighter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func qrCard(ticket: String) -> some View {
|
private func qrCard(image: Image?) -> some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
if let qr = QRCode.generate(from: ticket) {
|
if let image {
|
||||||
qr.interpolation(.none).resizable().scaledToFit().padding(14)
|
image.interpolation(.none).resizable().scaledToFit().padding(14)
|
||||||
} else {
|
} else {
|
||||||
ProgressView()
|
VStack(spacing: 10) {
|
||||||
|
Image(systemSymbol: .qrcode)
|
||||||
|
.font(.system(size: 36, weight: .medium))
|
||||||
|
Text(String(localized: L10n.Transfer.qrUnavailable))
|
||||||
|
.font(VniType.bodySmall)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.black.opacity(0.72))
|
||||||
|
.padding(22)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: 268, height: 268)
|
.frame(width: 268, height: 268)
|
||||||
@@ -305,6 +320,26 @@ struct TransferSharePanel: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum TransferInvitationPresentation: Equatable {
|
||||||
|
case preparing
|
||||||
|
case ready(String)
|
||||||
|
case unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Transfer {
|
||||||
|
var invitationPresentation: TransferInvitationPresentation {
|
||||||
|
switch status {
|
||||||
|
case .importing:
|
||||||
|
return .preparing
|
||||||
|
case .sharing:
|
||||||
|
guard let ticket, !ticket.isEmpty else { return .preparing }
|
||||||
|
return .ready(ticket)
|
||||||
|
case .receiving, .done, .failed, .cancelled, .stopped:
|
||||||
|
return .unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - QR generation (CoreImage)
|
// MARK: - QR generation (CoreImage)
|
||||||
|
|
||||||
enum QRCode {
|
enum QRCode {
|
||||||
@@ -361,6 +396,7 @@ extension ReceiverDeliveryStatus {
|
|||||||
case .refused: return L10n.Transfer.receiverRefused
|
case .refused: return L10n.Transfer.receiverRefused
|
||||||
case .expired: return L10n.Transfer.receiverExpired
|
case .expired: return L10n.Transfer.receiverExpired
|
||||||
case .completed: return L10n.Transfer.receiverCompleted
|
case .completed: return L10n.Transfer.receiverCompleted
|
||||||
|
case .failed: return L10n.Transfer.receiverFailed
|
||||||
case .unknown: return L10n.Transfer.receiverUnknown
|
case .unknown: return L10n.Transfer.receiverUnknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -368,7 +404,7 @@ extension ReceiverDeliveryStatus {
|
|||||||
func statusColor(_ colors: VniDropColors) -> Color {
|
func statusColor(_ colors: VniDropColors) -> Color {
|
||||||
switch self {
|
switch self {
|
||||||
case .completed: return colors.brandDefault
|
case .completed: return colors.brandDefault
|
||||||
case .refused, .expired: return colors.destructiveDefault
|
case .refused, .expired, .failed: return colors.destructiveDefault
|
||||||
default: return colors.foregroundLighter
|
default: return colors.foregroundLighter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ protocol BugReportService {
|
|||||||
/// Offline-safe no-op used until the diagnostics transport is configured.
|
/// Offline-safe no-op used until the diagnostics transport is configured.
|
||||||
struct NoopBugReportService: BugReportService {
|
struct NoopBugReportService: BugReportService {
|
||||||
func submit(_ draft: BugReportDraft, deviceInfo: DeviceInfo?) async -> Result<Void, Error> {
|
func submit(_ draft: BugReportDraft, deviceInfo: DeviceInfo?) async -> Result<Void, Error> {
|
||||||
.failure(InvitationError.message("Bug reporting is not configured"))
|
.failure(InvitationError.bugReportingUnavailable)
|
||||||
}
|
}
|
||||||
func previewLogBytes() async -> Int { 0 }
|
func previewLogBytes() async -> Int { 0 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ enum SettingsSection: Hashable {
|
|||||||
case preferences
|
case preferences
|
||||||
case appearance
|
case appearance
|
||||||
case notifications
|
case notifications
|
||||||
|
case network
|
||||||
case storage
|
case storage
|
||||||
case about
|
case about
|
||||||
case bugReport
|
case bugReport
|
||||||
@@ -17,6 +18,7 @@ enum SettingsSection: Hashable {
|
|||||||
case .preferences: return L10n.Preferences.title
|
case .preferences: return L10n.Preferences.title
|
||||||
case .appearance: return L10n.Appearance.title
|
case .appearance: return L10n.Appearance.title
|
||||||
case .notifications: return L10n.Notifications.title
|
case .notifications: return L10n.Notifications.title
|
||||||
|
case .network: return L10n.Settings.networkTitle
|
||||||
case .storage: return L10n.Storage.title
|
case .storage: return L10n.Storage.title
|
||||||
case .about: return L10n.About.title
|
case .about: return L10n.About.title
|
||||||
case .bugReport: return L10n.About.bugReport
|
case .bugReport: return L10n.About.bugReport
|
||||||
@@ -43,6 +45,14 @@ struct SettingsState: Equatable {
|
|||||||
var themeMode: ThemeMode = .system
|
var themeMode: ThemeMode = .system
|
||||||
var notificationPermission: NotificationPermission = .notDetermined
|
var notificationPermission: NotificationPermission = .notDetermined
|
||||||
var diagnosticsEnabled = false
|
var diagnosticsEnabled = false
|
||||||
|
var relayMode: RelayPreferenceMode = .automatic
|
||||||
|
var relayURLs: [String] = []
|
||||||
|
var relayValidationError: RelayConfigurationValidationError?
|
||||||
|
var relayConfigurationIsDirty = false
|
||||||
|
var isApplyingRelayConfiguration = false
|
||||||
|
var hasActiveNetworkWork = false
|
||||||
|
var endpointId: String?
|
||||||
|
var relayApplyErrorKey: String.LocalizationValue?
|
||||||
var deviceInfo: DeviceInfo?
|
var deviceInfo: DeviceInfo?
|
||||||
var appVersion = ""
|
var appVersion = ""
|
||||||
var isLoadingDeviceInfo = false
|
var isLoadingDeviceInfo = false
|
||||||
@@ -67,6 +77,13 @@ struct SettingsState: Equatable {
|
|||||||
&& lhs.themeMode == rhs.themeMode
|
&& lhs.themeMode == rhs.themeMode
|
||||||
&& lhs.notificationPermission == rhs.notificationPermission
|
&& lhs.notificationPermission == rhs.notificationPermission
|
||||||
&& lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion
|
&& lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion
|
||||||
|
&& lhs.relayMode == rhs.relayMode && lhs.relayURLs == rhs.relayURLs
|
||||||
|
&& lhs.relayValidationError == rhs.relayValidationError
|
||||||
|
&& lhs.relayConfigurationIsDirty == rhs.relayConfigurationIsDirty
|
||||||
|
&& lhs.isApplyingRelayConfiguration == rhs.isApplyingRelayConfiguration
|
||||||
|
&& lhs.hasActiveNetworkWork == rhs.hasActiveNetworkWork
|
||||||
|
&& lhs.endpointId == rhs.endpointId
|
||||||
|
&& lhs.relayApplyErrorKey == rhs.relayApplyErrorKey
|
||||||
&& lhs.isLoadingDeviceInfo == rhs.isLoadingDeviceInfo
|
&& lhs.isLoadingDeviceInfo == rhs.isLoadingDeviceInfo
|
||||||
&& lhs.bugWhatHappened == rhs.bugWhatHappened && lhs.bugExpected == rhs.bugExpected
|
&& lhs.bugWhatHappened == rhs.bugWhatHappened && lhs.bugExpected == rhs.bugExpected
|
||||||
&& lhs.bugSteps == rhs.bugSteps && lhs.bugContact == rhs.bugContact
|
&& lhs.bugSteps == rhs.bugSteps && lhs.bugContact == rhs.bugContact
|
||||||
@@ -98,6 +115,7 @@ final class SettingsModel: ObservableObject {
|
|||||||
|
|
||||||
private var usernamePersistTask: Task<Void, Never>?
|
private var usernamePersistTask: Task<Void, Never>?
|
||||||
private var hasLocalUsernameDraft = false
|
private var hasLocalUsernameDraft = false
|
||||||
|
private var hasRelayConfigurationDraft = false
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@@ -134,10 +152,29 @@ final class SettingsModel: ObservableObject {
|
|||||||
self.state.receiveFolder = folder
|
self.state.receiveFolder = folder
|
||||||
self.state.themeMode = prefs.themeMode
|
self.state.themeMode = prefs.themeMode
|
||||||
self.state.diagnosticsEnabled = prefs.diagnosticsEnabled
|
self.state.diagnosticsEnabled = prefs.diagnosticsEnabled
|
||||||
|
if !self.hasRelayConfigurationDraft {
|
||||||
|
self.state.relayMode = prefs.relayConfiguration.mode
|
||||||
|
self.state.relayURLs = prefs.relayConfiguration.relayURLs
|
||||||
|
self.state.relayConfigurationIsDirty = false
|
||||||
|
}
|
||||||
if folder != previousFolder { Task { await self.validateFolder(folder) } }
|
if folder != previousFolder { Task { await self.validateFolder(folder) } }
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
repository.statePublisher
|
||||||
|
.sink { [weak self] coreState in
|
||||||
|
guard let self else { return }
|
||||||
|
let hasActiveWork = (coreState.status?.activeTransfers ?? 0) > 0
|
||||||
|
|| (coreState.status?.activeShares ?? 0) > 0
|
||||||
|
|| coreState.transfers.contains(where: { $0.status.isActiveTransfer })
|
||||||
|
self.state.hasActiveNetworkWork = hasActiveWork
|
||||||
|
self.state.endpointId = coreState.status?.endpointId
|
||||||
|
if !hasActiveWork && self.state.relayApplyErrorKey == L10n.Relay.applyActiveTransfers {
|
||||||
|
self.state.relayApplyErrorKey = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
refreshNotificationPermission()
|
refreshNotificationPermission()
|
||||||
loadDeviceInfo()
|
loadDeviceInfo()
|
||||||
}
|
}
|
||||||
@@ -169,7 +206,7 @@ final class SettingsModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func onReceiveFolderPicked(_ folder: ReceiveFolder) { preferences.setReceiveFolder(folder) }
|
func onReceiveFolderPicked(_ folder: ReceiveFolder) { preferences.setReceiveFolder(folder) }
|
||||||
func onReceiveFolderPickFailed(_ reason: String) { messages.error(InvitationError.message(reason)) }
|
func onReceiveFolderPickFailed(_ reason: String) { messages.error(InvitationError.raw(reason)) }
|
||||||
func resetReceiveFolder() { preferences.resetReceiveFolder() }
|
func resetReceiveFolder() { preferences.resetReceiveFolder() }
|
||||||
|
|
||||||
/// Whether the current receive folder is the platform default (so the reset
|
/// Whether the current receive folder is the platform default (so the reset
|
||||||
@@ -204,6 +241,119 @@ final class SettingsModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Network
|
||||||
|
|
||||||
|
func setRelayMode(_ mode: RelayPreferenceMode) {
|
||||||
|
hasRelayConfigurationDraft = true
|
||||||
|
state.relayMode = mode
|
||||||
|
if mode.usesCustomRelayURLs && state.relayURLs.isEmpty { state.relayURLs = [""] }
|
||||||
|
updateRelayConfigurationDraft()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setRelayURL(_ value: String, at index: Int) {
|
||||||
|
guard state.relayURLs.indices.contains(index) else { return }
|
||||||
|
hasRelayConfigurationDraft = true
|
||||||
|
state.relayURLs[index] = value
|
||||||
|
updateRelayConfigurationDraft()
|
||||||
|
}
|
||||||
|
|
||||||
|
func addRelayURL() {
|
||||||
|
guard state.relayURLs.count < RelayConfigurationValidator.maximumRelayCount else { return }
|
||||||
|
hasRelayConfigurationDraft = true
|
||||||
|
state.relayURLs.append("")
|
||||||
|
updateRelayConfigurationDraft()
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeRelayURL(at index: Int) {
|
||||||
|
guard state.relayURLs.indices.contains(index) else { return }
|
||||||
|
hasRelayConfigurationDraft = true
|
||||||
|
state.relayURLs.remove(at: index)
|
||||||
|
if state.relayURLs.isEmpty { state.relayURLs = [""] }
|
||||||
|
updateRelayConfigurationDraft()
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyRelayConfiguration() {
|
||||||
|
guard !state.isApplyingRelayConfiguration, state.relayConfigurationIsDirty else { return }
|
||||||
|
|
||||||
|
let configuration: RelayConfiguration
|
||||||
|
do {
|
||||||
|
configuration = try RelayConfigurationValidator.validate(
|
||||||
|
mode: state.relayMode,
|
||||||
|
relayURLs: state.relayURLs,
|
||||||
|
retainedRelayURLs: preferences.preferences.relayConfiguration.relayURLs
|
||||||
|
)
|
||||||
|
} catch let error as RelayConfigurationValidationError {
|
||||||
|
state.relayValidationError = error
|
||||||
|
state.relayApplyErrorKey = nil
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let coreState = repository.state
|
||||||
|
let hasActiveWork = (coreState.status?.activeTransfers ?? 0) > 0
|
||||||
|
|| (coreState.status?.activeShares ?? 0) > 0
|
||||||
|
|| coreState.transfers.contains(where: { $0.status.isActiveTransfer })
|
||||||
|
guard !hasActiveWork else {
|
||||||
|
state.hasActiveNetworkWork = true
|
||||||
|
state.relayApplyErrorKey = L10n.Relay.applyActiveTransfers
|
||||||
|
messages.show(UiMessage(text: .resource(L10n.Relay.applyActiveTransfers), tone: .warning))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let previousConfiguration = preferences.preferences.relayConfiguration
|
||||||
|
state.relayValidationError = nil
|
||||||
|
state.relayApplyErrorKey = nil
|
||||||
|
state.isApplyingRelayConfiguration = true
|
||||||
|
Task {
|
||||||
|
let applyResult = await repository.initialize(
|
||||||
|
appDataDir: environment.defaultCoreDataDir,
|
||||||
|
networkConfiguration: configuration
|
||||||
|
)
|
||||||
|
switch applyResult {
|
||||||
|
case .success:
|
||||||
|
hasRelayConfigurationDraft = false
|
||||||
|
preferences.setRelayConfiguration(configuration)
|
||||||
|
state.isApplyingRelayConfiguration = false
|
||||||
|
state.relayConfigurationIsDirty = false
|
||||||
|
messages.show(UiMessage(text: .resource(L10n.Relay.settingsApplied), tone: .success))
|
||||||
|
case .failure(let error):
|
||||||
|
if let lifecycleError = error as? CoreNetworkLifecycleError {
|
||||||
|
state.isApplyingRelayConfiguration = false
|
||||||
|
switch lifecycleError {
|
||||||
|
case .activeNetworkWork:
|
||||||
|
state.hasActiveNetworkWork = true
|
||||||
|
state.relayApplyErrorKey = L10n.Relay.applyActiveTransfers
|
||||||
|
case .transitionInProgress:
|
||||||
|
state.relayApplyErrorKey = L10n.Relay.applyFailed
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let rollbackResult = await repository.initialize(
|
||||||
|
appDataDir: environment.defaultCoreDataDir,
|
||||||
|
networkConfiguration: previousConfiguration
|
||||||
|
)
|
||||||
|
state.isApplyingRelayConfiguration = false
|
||||||
|
if case .success = rollbackResult {
|
||||||
|
state.relayApplyErrorKey = L10n.Relay.applyFailed
|
||||||
|
messages.show(UiMessage(text: .resource(L10n.Relay.applyFailed), tone: .error))
|
||||||
|
} else {
|
||||||
|
state.relayApplyErrorKey = L10n.Relay.restoreFailed
|
||||||
|
messages.show(UiMessage(text: .resource(L10n.Relay.restoreFailed), tone: .error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateRelayConfigurationDraft() {
|
||||||
|
state.relayValidationError = nil
|
||||||
|
state.relayApplyErrorKey = nil
|
||||||
|
let saved = preferences.preferences.relayConfiguration
|
||||||
|
let draftURLs = state.relayMode.usesCustomRelayURLs ? state.relayURLs : saved.relayURLs
|
||||||
|
state.relayConfigurationIsDirty = saved != RelayConfiguration(mode: state.relayMode, relayURLs: draftURLs)
|
||||||
|
hasRelayConfigurationDraft = state.relayConfigurationIsDirty
|
||||||
|
}
|
||||||
|
|
||||||
func setBugWhatHappened(_ value: String) { state.bugWhatHappened = value }
|
func setBugWhatHappened(_ value: String) { state.bugWhatHappened = value }
|
||||||
func setBugExpected(_ value: String) { state.bugExpected = value }
|
func setBugExpected(_ value: String) { state.bugExpected = value }
|
||||||
func setBugSteps(_ value: String) { state.bugSteps = value }
|
func setBugSteps(_ value: String) { state.bugSteps = value }
|
||||||
@@ -325,7 +475,7 @@ final class SettingsModel: ObservableObject {
|
|||||||
loadStorageUsage()
|
loadStorageUsage()
|
||||||
messages.show(UiMessage(text: .resource(L10n.Storage.transfersDeleted), tone: .success))
|
messages.show(UiMessage(text: .resource(L10n.Storage.transfersDeleted), tone: .success))
|
||||||
} else {
|
} else {
|
||||||
messages.error(InvitationError.message("Could not delete \(failures) transfer records"))
|
messages.error(InvitationError.deleteRecordsFailed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,21 @@ struct SettingsScreen: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack(path: path) {
|
NavigationStack(path: path) {
|
||||||
Form {
|
Form {
|
||||||
|
#if os(iOS)
|
||||||
|
// iOS suspends the app in the background, so serving/receiving
|
||||||
|
// can't run indefinitely there (unlike macOS). Tell users up
|
||||||
|
// front so the platform limit doesn't read as a bug.
|
||||||
|
Section {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Label(String(localized: L10n.Settings.iosBackgroundNoticeTitle), systemSymbol: .moonZzz)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Text(String(localized: L10n.Settings.iosBackgroundNoticeBody))
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
Section {
|
Section {
|
||||||
NavigationLink(value: SettingsSection.preferences) {
|
NavigationLink(value: SettingsSection.preferences) {
|
||||||
SettingsRow(icon: .personCropCircle, title: String(localized: L10n.Preferences.title), value: model.state.username)
|
SettingsRow(icon: .personCropCircle, title: String(localized: L10n.Preferences.title), value: model.state.username)
|
||||||
@@ -39,6 +54,17 @@ struct SettingsScreen: View {
|
|||||||
NavigationLink(value: SettingsSection.storage) {
|
NavigationLink(value: SettingsSection.storage) {
|
||||||
SettingsRow(icon: .internaldrive, title: String(localized: L10n.Storage.title), value: nil)
|
SettingsRow(icon: .internaldrive, title: String(localized: L10n.Storage.title), value: nil)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
Section(String(localized: L10n.Settings.advancedTitle)) {
|
||||||
|
NavigationLink(value: SettingsSection.network) {
|
||||||
|
SettingsRow(
|
||||||
|
icon: .network,
|
||||||
|
title: String(localized: L10n.Settings.networkTitle),
|
||||||
|
value: relayModeLabel(model.state.relayMode)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section {
|
||||||
NavigationLink(value: SettingsSection.about) {
|
NavigationLink(value: SettingsSection.about) {
|
||||||
SettingsRow(icon: .infoCircle, title: String(localized: L10n.About.title), value: nil)
|
SettingsRow(icon: .infoCircle, title: String(localized: L10n.About.title), value: nil)
|
||||||
}
|
}
|
||||||
@@ -100,6 +126,8 @@ private struct SettingsSectionContent: View {
|
|||||||
AppearanceSettings(model: model)
|
AppearanceSettings(model: model)
|
||||||
case .notifications:
|
case .notifications:
|
||||||
NotificationSettings(model: model)
|
NotificationSettings(model: model)
|
||||||
|
case .network:
|
||||||
|
NetworkSettings(model: model)
|
||||||
case .storage:
|
case .storage:
|
||||||
StorageSettings(model: model)
|
StorageSettings(model: model)
|
||||||
case .about:
|
case .about:
|
||||||
@@ -110,6 +138,24 @@ private struct SettingsSectionContent: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func relayModeLabel(_ mode: RelayPreferenceMode) -> String {
|
||||||
|
switch mode {
|
||||||
|
case .automatic: return String(localized: L10n.Relay.modeAutomatic)
|
||||||
|
case .strictCustom: return String(localized: L10n.Relay.modeCustom)
|
||||||
|
case .customWithDirectFallback: return String(localized: L10n.Relay.modeCustomDirectFallback)
|
||||||
|
case .localOnly: return String(localized: L10n.Relay.modeLocalOnly)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func relayModeDescription(_ mode: RelayPreferenceMode) -> String.LocalizationValue {
|
||||||
|
switch mode {
|
||||||
|
case .automatic: return L10n.Relay.modeAutomaticDescription
|
||||||
|
case .strictCustom: return L10n.Relay.modeCustomDescription
|
||||||
|
case .customWithDirectFallback: return L10n.Relay.modeCustomDirectFallbackDescription
|
||||||
|
case .localOnly: return L10n.Relay.modeLocalOnlyDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct SettingsRow: View {
|
struct SettingsRow: View {
|
||||||
let icon: SFSymbol
|
let icon: SFSymbol
|
||||||
let title: String
|
let title: String
|
||||||
|
|||||||
@@ -74,6 +74,173 @@ struct NotificationSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct NetworkSettings: View {
|
||||||
|
@ObservedObject var model: SettingsModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section {
|
||||||
|
Picker(
|
||||||
|
"",
|
||||||
|
selection: Binding(get: { model.state.relayMode }, set: { model.setRelayMode($0) })
|
||||||
|
) {
|
||||||
|
ForEach(RelayPreferenceMode.allCases, id: \.self) { mode in
|
||||||
|
Text(relayModeLabel(mode)).tag(mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.inline)
|
||||||
|
.labelsHidden()
|
||||||
|
.disabled(model.state.isApplyingRelayConfiguration)
|
||||||
|
} header: {
|
||||||
|
Text(String(localized: L10n.Settings.networkTitle))
|
||||||
|
} footer: {
|
||||||
|
Text(String(localized: relayModeDescription(model.state.relayMode)))
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Label {
|
||||||
|
Text(String(localized: L10n.Relay.privacyDescription))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
} icon: {
|
||||||
|
Image(systemSymbol: .lockShield)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let endpointId = model.state.endpointId, !endpointId.isEmpty {
|
||||||
|
Section {
|
||||||
|
Text(L10n.Approval.endpointId(deviceId: endpointId))
|
||||||
|
.font(.footnote.monospaced())
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if model.state.relayMode.usesCustomRelayURLs {
|
||||||
|
Section {
|
||||||
|
if model.state.relayMode == .strictCustom {
|
||||||
|
Label {
|
||||||
|
Text(String(localized: L10n.Relay.strictWarning))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
} icon: {
|
||||||
|
Image(systemSymbol: .exclamationmarkShieldFill)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(Array(model.state.relayURLs.indices), id: \.self) { index in
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack {
|
||||||
|
TextField(
|
||||||
|
"",
|
||||||
|
text: Binding(
|
||||||
|
get: {
|
||||||
|
model.state.relayURLs.indices.contains(index)
|
||||||
|
? model.state.relayURLs[index]
|
||||||
|
: ""
|
||||||
|
},
|
||||||
|
set: { model.setRelayURL($0, at: index) }
|
||||||
|
),
|
||||||
|
// `Text(verbatim:)` avoids macOS markdown-linkifying the
|
||||||
|
// URL-shaped placeholder into a purple link.
|
||||||
|
prompt: Text(verbatim: "https://relay.example.com")
|
||||||
|
)
|
||||||
|
.labelsHidden()
|
||||||
|
#if os(iOS)
|
||||||
|
.keyboardType(.URL)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
#endif
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.disabled(model.state.isApplyingRelayConfiguration)
|
||||||
|
|
||||||
|
Button(role: .destructive) {
|
||||||
|
model.removeRelayURL(at: index)
|
||||||
|
} label: {
|
||||||
|
Image(systemSymbol: .minusCircleFill)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.accessibilityLabel(Text(String(localized: L10n.Relay.removeUrl)))
|
||||||
|
.disabled(model.state.isApplyingRelayConfiguration)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = model.state.relayValidationError, error.urlIndex == index {
|
||||||
|
Text(relayValidationMessage(error))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: model.addRelayURL) {
|
||||||
|
Label(String(localized: L10n.Relay.addUrl), systemSymbol: .plusCircle)
|
||||||
|
}
|
||||||
|
.disabled(
|
||||||
|
model.state.relayURLs.count >= RelayConfigurationValidator.maximumRelayCount
|
||||||
|
|| model.state.isApplyingRelayConfiguration
|
||||||
|
)
|
||||||
|
} header: {
|
||||||
|
Text(String(localized: L10n.Relay.customUrlsLabel))
|
||||||
|
} footer: {
|
||||||
|
Text(String(localized: L10n.Relay.customUrlsHelp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = model.state.relayValidationError, error.urlIndex == nil {
|
||||||
|
Section {
|
||||||
|
Label {
|
||||||
|
Text(relayValidationMessage(error))
|
||||||
|
} icon: {
|
||||||
|
Image(systemSymbol: .exclamationmarkTriangleFill)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if model.state.hasActiveNetworkWork || model.state.relayApplyErrorKey != nil {
|
||||||
|
Section {
|
||||||
|
Label {
|
||||||
|
Text(String(localized: model.state.hasActiveNetworkWork ? L10n.Relay.applyActiveTransfers : (model.state.relayApplyErrorKey ?? L10n.Relay.applyFailed)))
|
||||||
|
} icon: {
|
||||||
|
Image(systemSymbol: .exclamationmarkTriangleFill)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button(action: model.applyRelayConfiguration) {
|
||||||
|
HStack {
|
||||||
|
Text(String(localized: model.state.isApplyingRelayConfiguration ? L10n.Relay.applying : L10n.Relay.apply))
|
||||||
|
if model.state.isApplyingRelayConfiguration {
|
||||||
|
Spacer()
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(
|
||||||
|
!model.state.relayConfigurationIsDirty
|
||||||
|
|| model.state.isApplyingRelayConfiguration
|
||||||
|
|| model.state.hasActiveNetworkWork
|
||||||
|
)
|
||||||
|
} footer: {
|
||||||
|
Text(String(localized: L10n.Relay.applyRestartDescription))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func relayValidationMessage(_ error: RelayConfigurationValidationError) -> String {
|
||||||
|
switch error {
|
||||||
|
case .missingURL:
|
||||||
|
return String(localized: L10n.Relay.validationMissingUrl)
|
||||||
|
case .tooManyURLs:
|
||||||
|
return L10n.Relay.validationTooManyUrls(maximum: RelayConfigurationValidator.maximumRelayCount)
|
||||||
|
case .httpsRequired(let index):
|
||||||
|
return L10n.Relay.validationHttpsRequired(line: index + 1)
|
||||||
|
case .invalidURL(let index):
|
||||||
|
return L10n.Relay.validationInvalidUrl(line: index + 1)
|
||||||
|
case .duplicateURL(let index):
|
||||||
|
return L10n.Relay.validationDuplicateUrl(line: index + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct StorageSettings: View {
|
struct StorageSettings: View {
|
||||||
@ObservedObject var model: SettingsModel
|
@ObservedObject var model: SettingsModel
|
||||||
@State private var showDeleteConfirmation = false
|
@State private var showDeleteConfirmation = false
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ struct IosFileSystemService: FileSystemService {
|
|||||||
|
|
||||||
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
|
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
|
||||||
guard canRevealReceiveFolder(folder) else {
|
guard canRevealReceiveFolder(folder) else {
|
||||||
return .failure(InvitationError.message("The receive folder is not VniDrop Documents"))
|
return .failure(InvitationError.filesystemUnavailable)
|
||||||
}
|
}
|
||||||
guard let url = URL(string: "shareddocuments://\(folder.value)") else {
|
guard let url = URL(string: "shareddocuments://\(folder.value)") else {
|
||||||
return .failure(InvitationError.message("The Files location URL is unavailable"))
|
return .failure(InvitationError.filesystemUnavailable)
|
||||||
}
|
}
|
||||||
let opened = await withCheckedContinuation { continuation in
|
let opened = await withCheckedContinuation { continuation in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@@ -44,7 +44,7 @@ struct IosFileSystemService: FileSystemService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return opened ? .success(()) : .failure(InvitationError.message("Could not open VniDrop Documents in Files"))
|
return opened ? .success(()) : .failure(InvitationError.filesystemUnavailable)
|
||||||
}
|
}
|
||||||
|
|
||||||
func discardPickedFiles(_ files: [PickedShareFile]) async {
|
func discardPickedFiles(_ files: [PickedShareFile]) async {
|
||||||
@@ -62,7 +62,7 @@ struct IosFileSystemService: FileSystemService {
|
|||||||
accessPolicy: ShareAccessPolicy
|
accessPolicy: ShareAccessPolicy
|
||||||
) async -> Result<Share, Error> {
|
) async -> Result<Share, Error> {
|
||||||
guard !files.isEmpty else {
|
guard !files.isEmpty else {
|
||||||
return .failure(InvitationError.message("Select at least one file to share"))
|
return .failure(InvitationError.shareEmpty)
|
||||||
}
|
}
|
||||||
let sources = files.map { $0.toIosShareSource() }
|
let sources = files.map { $0.toIosShareSource() }
|
||||||
return await repository.shareSources(
|
return await repository.shareSources(
|
||||||
|
|||||||
@@ -42,8 +42,24 @@ struct MacFileSystemService: FileSystemService {
|
|||||||
accessPolicy: ShareAccessPolicy
|
accessPolicy: ShareAccessPolicy
|
||||||
) async -> Result<Share, Error> {
|
) async -> Result<Share, Error> {
|
||||||
guard !files.isEmpty else {
|
guard !files.isEmpty else {
|
||||||
return .failure(InvitationError.message("Select at least one file to share"))
|
return .failure(InvitationError.shareEmpty)
|
||||||
}
|
}
|
||||||
|
// Re-acquire security-scoped access to every picked source (from the bookmark
|
||||||
|
// captured at pick time) and hold it across the whole share call. The core
|
||||||
|
// imports the bytes during shareFiles(), so access only needs to survive that
|
||||||
|
// call; without this, the import fails with EPERM under the App Store sandbox.
|
||||||
|
var scopedURLs: [URL] = []
|
||||||
|
for file in files {
|
||||||
|
guard let bookmark = file.securityScopeBookmark else { continue }
|
||||||
|
var stale = false
|
||||||
|
guard let url = try? URL(
|
||||||
|
resolvingBookmarkData: bookmark, options: .withSecurityScope,
|
||||||
|
relativeTo: nil, bookmarkDataIsStale: &stale
|
||||||
|
), url.startAccessingSecurityScopedResource() else { continue }
|
||||||
|
scopedURLs.append(url)
|
||||||
|
}
|
||||||
|
defer { scopedURLs.forEach { $0.stopAccessingSecurityScopedResource() } }
|
||||||
|
|
||||||
let sources = files.map {
|
let sources = files.map {
|
||||||
ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory)
|
ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,9 +107,15 @@ enum PickerSupport {
|
|||||||
)
|
)
|
||||||
#else
|
#else
|
||||||
let size = isDirectory ? nil : (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize.map { UInt64($0) }
|
let size = isDirectory ? nil : (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize.map { UInt64($0) }
|
||||||
|
// Capture a security-scoped bookmark while the picker's scope is still held,
|
||||||
|
// so the core can re-acquire access to open the file at import time (under
|
||||||
|
// the App Store sandbox). Non-sandboxed builds don't need it but it's harmless.
|
||||||
|
let bookmark = try? url.bookmarkData(
|
||||||
|
options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil
|
||||||
|
)
|
||||||
return PickedShareFile(
|
return PickedShareFile(
|
||||||
value: url.path, displayName: url.lastPathComponent, sizeBytes: size,
|
value: url.path, displayName: url.lastPathComponent, sizeBytes: size,
|
||||||
isTemporaryCopy: false, isDirectory: isDirectory
|
isTemporaryCopy: false, isDirectory: isDirectory, securityScopeBookmark: bookmark
|
||||||
)
|
)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
|
|||||||
picker.delegate = self
|
picker.delegate = self
|
||||||
picker.modalPresentationStyle = .formSheet
|
picker.modalPresentationStyle = .formSheet
|
||||||
guard let presenter = topPresenter() else {
|
guard let presenter = topPresenter() else {
|
||||||
return onResult(.failure(InvitationError.message("Could not find an iOS view controller")))
|
return onResult(.failure(InvitationError.viewControllerUnavailable))
|
||||||
}
|
}
|
||||||
presenter.present(picker, animated: true)
|
presenter.present(picker, animated: true)
|
||||||
}
|
}
|
||||||
@@ -37,12 +37,12 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
|
|||||||
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) {
|
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) {
|
||||||
cancel()
|
cancel()
|
||||||
guard let presenter = topPresenter() else {
|
guard let presenter = topPresenter() else {
|
||||||
return onResult(.failure(InvitationError.message("Could not find an iOS view controller")))
|
return onResult(.failure(InvitationError.viewControllerUnavailable))
|
||||||
}
|
}
|
||||||
ensureCameraAccess { [weak self] granted in
|
ensureCameraAccess { [weak self] granted in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
guard granted else {
|
guard granted else {
|
||||||
return onResult(.failure(InvitationError.message("Camera access is required to scan QR codes")))
|
return onResult(.failure(InvitationError.cameraUnavailable))
|
||||||
}
|
}
|
||||||
let scanner = QrScannerViewController { result in
|
let scanner = QrScannerViewController { result in
|
||||||
self.qrController = nil
|
self.qrController = nil
|
||||||
@@ -57,7 +57,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
|
|||||||
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
|
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
|
||||||
cancel()
|
cancel()
|
||||||
guard NFCNDEFReaderSession.readingAvailable else {
|
guard NFCNDEFReaderSession.readingAvailable else {
|
||||||
return onResult(.failure(InvitationError.message("NFC reading is unavailable on this device")))
|
return onResult(.failure(InvitationError.nfcUnavailable))
|
||||||
}
|
}
|
||||||
let reader = InvitationNfcReader { [weak self] result in
|
let reader = InvitationNfcReader { [weak self] result in
|
||||||
self?.nfcReader = nil
|
self?.nfcReader = nil
|
||||||
@@ -80,7 +80,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
|
|||||||
let result = documentResult
|
let result = documentResult
|
||||||
documentResult = nil
|
documentResult = nil
|
||||||
result?(Result {
|
result?(Result {
|
||||||
guard let url = urls.first else { throw InvitationError.message("The selected invitation URL was invalid") }
|
guard let url = urls.first else { throw InvitationError.invalidInvitationURL }
|
||||||
let started = url.startAccessingSecurityScopedResource()
|
let started = url.startAccessingSecurityScopedResource()
|
||||||
defer { if started { url.stopAccessingSecurityScopedResource() } }
|
defer { if started { url.stopAccessingSecurityScopedResource() } }
|
||||||
let data = try Data(contentsOf: url)
|
let data = try Data(contentsOf: url)
|
||||||
@@ -157,19 +157,19 @@ final class QrScannerViewController: UIViewController, AVCaptureMetadataOutputOb
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cancelScan() {
|
func cancelScan() {
|
||||||
finish(.failure(InvitationError.message("QR scanning was cancelled")))
|
finish(.failure(InvitationError.cancelled))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureSession() {
|
private func configureSession() {
|
||||||
guard let device = AVCaptureDevice.default(for: .video),
|
guard let device = AVCaptureDevice.default(for: .video),
|
||||||
let input = try? AVCaptureDeviceInput(device: device),
|
let input = try? AVCaptureDeviceInput(device: device),
|
||||||
session.canAddInput(input) else {
|
session.canAddInput(input) else {
|
||||||
return finish(.failure(InvitationError.message("No camera is available")))
|
return finish(.failure(InvitationError.cameraUnavailable))
|
||||||
}
|
}
|
||||||
session.addInput(input)
|
session.addInput(input)
|
||||||
let output = AVCaptureMetadataOutput()
|
let output = AVCaptureMetadataOutput()
|
||||||
guard session.canAddOutput(output) else {
|
guard session.canAddOutput(output) else {
|
||||||
return finish(.failure(InvitationError.message("Could not configure the QR scanner")))
|
return finish(.failure(InvitationError.cameraUnavailable))
|
||||||
}
|
}
|
||||||
session.addOutput(output)
|
session.addOutput(output)
|
||||||
output.setMetadataObjectsDelegate(self, queue: .main)
|
output.setMetadataObjectsDelegate(self, queue: .main)
|
||||||
@@ -220,7 +220,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec
|
|||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: true)
|
let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: true)
|
||||||
reader.alertMessage = "Hold your iPhone near a VniDrop invitation tag"
|
reader.alertMessage = String(localized: L10n.Receive.nfcWaiting)
|
||||||
session = reader
|
session = reader
|
||||||
reader.begin()
|
reader.begin()
|
||||||
}
|
}
|
||||||
@@ -233,7 +233,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec
|
|||||||
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
|
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
|
||||||
if finished { return }
|
if finished { return }
|
||||||
let cancelled = (error as NSError).code == 200
|
let cancelled = (error as NSError).code == 200
|
||||||
finish(.failure(InvitationError.message(cancelled ? "NFC reading was cancelled" : error.localizedDescription)))
|
finish(.failure(cancelled ? InvitationError.cancelled : InvitationError.raw(error.localizedDescription)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
|
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
|
||||||
@@ -242,7 +242,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec
|
|||||||
.flatMap { $0.records }
|
.flatMap { $0.records }
|
||||||
.compactMap { payloadAsInvitation($0) }
|
.compactMap { payloadAsInvitation($0) }
|
||||||
.first
|
.first
|
||||||
guard let ticket else { throw InvitationError.message("This NFC tag does not contain a VniDrop invitation") }
|
guard let ticket else { throw InvitationError.nfcFailed }
|
||||||
return ticket
|
return ticket
|
||||||
}
|
}
|
||||||
session.invalidate()
|
session.invalidate()
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ final class MacReceiveInvitationActions: ReceiveInvitationActions {
|
|||||||
}
|
}
|
||||||
panel.begin { response in
|
panel.begin { response in
|
||||||
guard response == .OK, let url = panel.url else {
|
guard response == .OK, let url = panel.url else {
|
||||||
onResult(.failure(InvitationError.message("cancelled")))
|
onResult(.failure(InvitationError.cancelled))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
onResult(Result {
|
onResult(Result {
|
||||||
@@ -33,11 +33,11 @@ final class MacReceiveInvitationActions: ReceiveInvitationActions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) {
|
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) {
|
||||||
onResult(.failure(InvitationError.message("QR scanning is unavailable on macOS")))
|
onResult(.failure(InvitationError.qrUnavailable))
|
||||||
}
|
}
|
||||||
|
|
||||||
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
|
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
|
||||||
onResult(.failure(InvitationError.message("NFC is unavailable on macOS")))
|
onResult(.failure(InvitationError.nfcUnavailable))
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancel() {}
|
func cancel() {}
|
||||||
|
|||||||
49
apple/VniDrop/Platform/SparkleUpdater.swift
Normal file
49
apple/VniDrop/Platform/SparkleUpdater.swift
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
#if DIRECT_DISTRIBUTION && os(macOS)
|
||||||
|
import Combine
|
||||||
|
import Sparkle
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Owns the Sparkle updater for the direct-download (.dmg) build.
|
||||||
|
///
|
||||||
|
/// Compiled only under `DIRECT_DISTRIBUTION`, so the App Store / TestFlight target
|
||||||
|
/// (which must not ship a self-updater) never compiles or links Sparkle. The feed
|
||||||
|
/// URL and public EdDSA key are read from Info.plist (`SUFeedURL`, `SUPublicEDKey`).
|
||||||
|
@MainActor
|
||||||
|
final class SparkleUpdaterController: ObservableObject {
|
||||||
|
private let updaterController: SPUStandardUpdaterController
|
||||||
|
/// Mirrors `SPUUpdater.canCheckForUpdates` so the menu item can disable itself
|
||||||
|
/// while a check is already in flight.
|
||||||
|
@Published private(set) var canCheckForUpdates = false
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// `startingUpdater: true` begins the automatic background check schedule.
|
||||||
|
updaterController = SPUStandardUpdaterController(
|
||||||
|
startingUpdater: true,
|
||||||
|
updaterDelegate: nil,
|
||||||
|
userDriverDelegate: nil
|
||||||
|
)
|
||||||
|
updaterController.updater
|
||||||
|
.publisher(for: \.canCheckForUpdates)
|
||||||
|
.assign(to: &$canCheckForUpdates)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkForUpdates() {
|
||||||
|
updaterController.checkForUpdates(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a "Check for Updates…" item to the application menu (right after the
|
||||||
|
/// standard "About VniDrop" item), matching the macOS convention.
|
||||||
|
struct UpdatesCommands: Commands {
|
||||||
|
@ObservedObject var controller: SparkleUpdaterController
|
||||||
|
|
||||||
|
var body: some Commands {
|
||||||
|
CommandGroup(after: .appInfo) {
|
||||||
|
Button(String(localized: L10n.Updates.check)) {
|
||||||
|
controller.checkForUpdates()
|
||||||
|
}
|
||||||
|
.disabled(!controller.canCheckForUpdates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -36,7 +36,7 @@ final class IosTransferShareActions: NSObject, TransferShareActions {
|
|||||||
func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) {
|
func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) {
|
||||||
cancelNfcWrite()
|
cancelNfcWrite()
|
||||||
guard NFCNDEFReaderSession.readingAvailable else {
|
guard NFCNDEFReaderSession.readingAvailable else {
|
||||||
onResult(.failure(InvitationError.message("NFC is unavailable on this device")))
|
onResult(.failure(InvitationError.nfcUnavailable))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let writer = InvitationNfcWriter(ticket: ticket) { [weak self] result in
|
let writer = InvitationNfcWriter(ticket: ticket) { [weak self] result in
|
||||||
@@ -55,7 +55,7 @@ final class IosTransferShareActions: NSObject, TransferShareActions {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private func present(_ controller: UIViewController) throws {
|
private func present(_ controller: UIViewController) throws {
|
||||||
guard let presenter = topPresenter() else {
|
guard let presenter = topPresenter() else {
|
||||||
throw InvitationError.message("Could not find an iOS view controller")
|
throw InvitationError.viewControllerUnavailable
|
||||||
}
|
}
|
||||||
presenter.present(controller, animated: true)
|
presenter.present(controller, animated: true)
|
||||||
}
|
}
|
||||||
@@ -76,7 +76,7 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchec
|
|||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: false)
|
let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: false)
|
||||||
reader.alertMessage = "Hold your iPhone near a writable NFC tag"
|
reader.alertMessage = String(localized: L10n.Transfer.nfcWaiting)
|
||||||
session = reader
|
session = reader
|
||||||
reader.begin()
|
reader.begin()
|
||||||
}
|
}
|
||||||
@@ -89,14 +89,14 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchec
|
|||||||
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
|
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
|
||||||
if finished { return }
|
if finished { return }
|
||||||
let cancelled = (error as NSError).code == 200 // readerSessionInvalidationErrorUserCanceled
|
let cancelled = (error as NSError).code == 200 // readerSessionInvalidationErrorUserCanceled
|
||||||
finish(.failure(InvitationError.message(cancelled ? "NFC writing was cancelled" : error.localizedDescription)))
|
finish(.failure(cancelled ? InvitationError.cancelled : InvitationError.raw(error.localizedDescription)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {}
|
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {}
|
||||||
|
|
||||||
func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) {
|
func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) {
|
||||||
guard let firstTag = tags.first else {
|
guard let firstTag = tags.first else {
|
||||||
return finish(.failure(InvitationError.message("No NFC tag was detected")))
|
return finish(.failure(InvitationError.nfcFailed))
|
||||||
}
|
}
|
||||||
// CoreNFC completion handlers run on the session's `.main` queue; these
|
// CoreNFC completion handlers run on the session's `.main` queue; these
|
||||||
// framework values are safe to use there.
|
// framework values are safe to use there.
|
||||||
@@ -109,18 +109,18 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchec
|
|||||||
if let queryError { return self.finish(.failure(queryError)) }
|
if let queryError { return self.finish(.failure(queryError)) }
|
||||||
switch status {
|
switch status {
|
||||||
case .notSupported:
|
case .notSupported:
|
||||||
self.finish(.failure(InvitationError.message("This NFC tag does not support NDEF")))
|
self.finish(.failure(InvitationError.nfcFailed))
|
||||||
case .readOnly:
|
case .readOnly:
|
||||||
self.finish(.failure(InvitationError.message("This NFC tag is read-only")))
|
self.finish(.failure(InvitationError.nfcFailed))
|
||||||
default:
|
default:
|
||||||
guard let message = self.invitationMessage() else {
|
guard let message = self.invitationMessage() else {
|
||||||
return self.finish(.failure(InvitationError.message("Could not encode the invitation for NFC")))
|
return self.finish(.failure(InvitationError.nfcFailed))
|
||||||
}
|
}
|
||||||
tag.writeNDEF(message) { writeError in
|
tag.writeNDEF(message) { writeError in
|
||||||
if let writeError {
|
if let writeError {
|
||||||
self.finish(.failure(writeError))
|
self.finish(.failure(writeError))
|
||||||
} else {
|
} else {
|
||||||
session.alertMessage = "Invitation written"
|
session.alertMessage = String(localized: L10n.Transfer.nfcWritten)
|
||||||
session.invalidate()
|
session.invalidate()
|
||||||
self.finish(.success(()))
|
self.finish(.success(()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ final class MacTransferShareActions: TransferShareActions {
|
|||||||
panel.allowedContentTypes = []
|
panel.allowedContentTypes = []
|
||||||
panel.begin { response in
|
panel.begin { response in
|
||||||
guard response == .OK, let url = panel.url else {
|
guard response == .OK, let url = panel.url else {
|
||||||
onResult(.failure(InvitationError.message("cancelled")))
|
onResult(.failure(InvitationError.cancelled))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
onResult(Result { try ticket.write(to: url, atomically: true, encoding: .utf8) })
|
onResult(Result { try ticket.write(to: url, atomically: true, encoding: .utf8) })
|
||||||
@@ -28,7 +28,7 @@ final class MacTransferShareActions: TransferShareActions {
|
|||||||
do {
|
do {
|
||||||
let url = try writeTemporaryInvitation(ticket: ticket, transferName: transferName)
|
let url = try writeTemporaryInvitation(ticket: ticket, transferName: transferName)
|
||||||
guard let view = NSApp.keyWindow?.contentView else {
|
guard let view = NSApp.keyWindow?.contentView else {
|
||||||
onResult(.failure(InvitationError.message("No window available")))
|
onResult(.failure(InvitationError.noWindowAvailable))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let picker = NSSharingServicePicker(items: [url])
|
let picker = NSSharingServicePicker(items: [url])
|
||||||
@@ -40,7 +40,7 @@ final class MacTransferShareActions: TransferShareActions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) {
|
func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) {
|
||||||
onResult(.failure(InvitationError.message("NFC is unavailable on macOS")))
|
onResult(.failure(InvitationError.nfcUnavailable))
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancelNfcWrite() {}
|
func cancelNfcWrite() {}
|
||||||
|
|||||||
8
apple/VniDrop/Resources/AppIcon.icon/Assets/Drop.svg
Normal file
8
apple/VniDrop/Resources/AppIcon.icon/Assets/Drop.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<path d="M520.4,431.72C495.8,464.52 443.32,530.12 420.36,577.68C390.84,636.72 403.96,699.04 446.6,731.84C487.6,758.08 549.92,758.08 592.56,730.2C633.56,700.68 646.68,636.72 620.44,577.68C597.48,530.12 546.64,464.52 520.4,431.72Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(302.875,217.566,-217.566,302.875,404.439,431.72)"><stop offset="0" style="stop-color:rgb(168,85,247);stop-opacity:1"/><stop offset="0.48" style="stop-color:rgb(157,77,244);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(124,42,239);stop-opacity:1"/></linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
17
apple/VniDrop/Resources/AppIcon.icon/Assets/Mask.svg
Normal file
17
apple/VniDrop/Resources/AppIcon.icon/Assets/Mask.svg
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<defs>
|
||||||
|
<mask id="Mask">
|
||||||
|
|
||||||
|
<g transform="matrix(1,-0,-0,1,0,0)"><image id="_Image2" width="1024px" height="1024px" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAAAAABadnRfAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAH6ElEQVR4nO3cv2udVRjA8ZM6CU0HFVTUTK2NWA1IUCdFg24KDkoN+GOwi4IgHfwDnGzVRVB0EQvi4ODYRWILdXMwWsQgOBRbQTvlihA0qX9Blfu+J/c55z6fzx+Q82S43zznvW9bCgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAkNlC9AAwwMLty8vLR25aPLR48EC9n7r752SyPZlc+mlr67dr9X5sywSA3iwcXlt77OZ9PmSy9cPXG5f3+ZAGCABdWXjg5aeXZnXY1sbGuauzOgz4X99fm63ds8/fGP077ycbAF0JuJpvf3Hmwt7sj50NAaArMc/mfjn9yU7IwftOAOhK1MP5K6c//ivo6H0lAHQl7tu5P977YDvs8H0jAHQl8uv5309+NndvBwgAXYn9BJ5/9cfQ8+ur+BoVzLtHN98+GD1DXTYAuhK+g19a/yZ6hJpsADCNpfNvztOHxgZAV8I3gFLK2Rfn5/1gAaArLQSgXD5+IXqEWuZpm4HZuOPcC9Ej1CIAMLUbzpyMHqESAYAB3jk1H7fn+fgtSKOJZwCllFI+PfF39AgVCABdaScA5cvn/okeYTxXABjmmQ/n4M+nAMBAr7wVPcF4c9AwMmnoClBKef396AnGEgC60lYAyvrn0ROMJAB0pbEA7Dz8XfQI4wgAXWksAOXn1b7/myAPAWGEIx/1/TdUAGCM4yeiJxil73yRTmtXgFJ2HtqMHmEEAaAr7QWgbK52/EagKwCMs/Ja9AQj2ADoSoMbQJksX4keYTAbAIy0+G70BMPZAOhKixtAKU98FT3BUAJAV9oMwNa9u9EjDOQKAKMdfTZ6gqFsAHSlzQ2gXFzZix5hGBsAjHfsqegJBrIB0JVGN4Dy7YOtTvbfbABQweqT0RMMIwBQwxvRAwzjCkBXml209+7q8nVAGwDUcGA9eoJBbAB0pdkNoFy8v93Zrs8GAFUcW4meYAgBgDpeih5gCFcAutLwmv3rUsPDXY8NAOq483D0BAMIAFSyFj3AAAIAlTwePcAAngHQlZav2Vdv7e+fBNoAoJJb7oueYHoCALU8Ej3A9AQAarkneoDpCQDUcjR6gOkJANSyHD3A9HwLQFda/haglEOT6AmmZQOAau6OHmBqAgDV9HcHEACo5rboAaYmAFDNYvQAUxMAqEYAIDEBgMQEABITAEhMACCx/j5O/U0MVCMAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwc/8CNInt3t3vKtwAAAAASUVORK5CYII="/>
|
||||||
|
</g>
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
<g mask="url(#Mask)">
|
||||||
|
<path d="M688,148L782,148C832,148 842,171.8 847.48,210L847.48,303.68L688,148Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(110.156,162.233,-162.233,110.156,683.48,144.6)"><stop offset="0" style="stop-color:rgb(242,221,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(192,132,252);stop-opacity:1"/></linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
8
apple/VniDrop/Resources/AppIcon.icon/Assets/U.svg
Normal file
8
apple/VniDrop/Resources/AppIcon.icon/Assets/U.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<path d="M236.68,148L338.36,148C366.24,148 387.56,170.96 387.56,198.84L387.56,564.56C387.56,597.36 372.8,620.32 372.8,646.56C372.8,725.28 436.76,787.6 522.04,787.6C607.32,787.6 668,725.28 668,646.56C668,620.32 656.52,597.36 656.52,564.56L656.52,198.84C656.52,170.96 677.84,148 705.72,148L781.16,148C817.24,148 846.76,177.52 846.76,213.6L846.76,564.56C846.76,738.4 704.08,879.44 522.04,879.44C340,879.44 194.04,738.4 194.04,564.56L194.04,374.32L220.28,374.32L220.28,305.44C195.68,305.44 176,297.24 176,280.84L176,246.4C176,231.64 187.48,220.16 202.24,220.16L236.68,220.16L236.68,148ZM256.36,239.84C251.44,239.84 248.16,244.76 248.16,249.68L248.16,275.92C248.16,282.48 253.08,285.76 259.64,285.76L282.6,285.76C289.16,285.76 292.44,280.84 292.44,274.28L292.44,251.32C292.44,244.76 287.52,239.84 280.96,239.84L256.36,239.84Z" style="fill:url(#_Linear1);"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(728.706,668.252,-668.252,728.706,176,148)"><stop offset="0" style="stop-color:rgb(168,85,247);stop-opacity:1"/><stop offset="0.48" style="stop-color:rgb(157,77,244);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(124,42,239);stop-opacity:1"/></linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
62
apple/VniDrop/Resources/AppIcon.icon/icon.json
Normal file
62
apple/VniDrop/Resources/AppIcon.icon/icon.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"fill": {
|
||||||
|
"linear-gradient": [
|
||||||
|
"extended-gray:1.00000,1.00000",
|
||||||
|
"display-p3:0.55433,0.59923,0.92884,1.00000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"blend-mode": "normal",
|
||||||
|
"blur-material": null,
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"image-name": "Mask.svg",
|
||||||
|
"name": "Mask"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lighting": "individual",
|
||||||
|
"refractivity": {
|
||||||
|
"depth": 0.5,
|
||||||
|
"enabled": true,
|
||||||
|
"strength": 0
|
||||||
|
},
|
||||||
|
"shadow": {
|
||||||
|
"kind": "neutral",
|
||||||
|
"opacity": 0.6
|
||||||
|
},
|
||||||
|
"specular": true,
|
||||||
|
"translucency": {
|
||||||
|
"enabled": true,
|
||||||
|
"value": 0.8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"image-name": "Drop.svg",
|
||||||
|
"name": "Drop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"image-name": "U.svg",
|
||||||
|
"name": "U"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lighting": "combined",
|
||||||
|
"shadow": {
|
||||||
|
"kind": "neutral",
|
||||||
|
"opacity": 0.6
|
||||||
|
},
|
||||||
|
"translucency": {
|
||||||
|
"enabled": true,
|
||||||
|
"value": 0.4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supported-platforms": {
|
||||||
|
"circles": [
|
||||||
|
"watchOS"
|
||||||
|
],
|
||||||
|
"squares": "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{ "idiom" : "universal", "platform" : "ios", "size" : "1024x1024", "filename" : "app-icon.png" },
|
|
||||||
{ "idiom" : "mac", "scale" : "2x", "size" : "512x512", "filename" : "app-icon.png" }
|
|
||||||
],
|
|
||||||
"info" : { "author" : "xcode", "version" : 1 }
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 49 KiB |
@@ -20,6 +20,8 @@
|
|||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>VniDrop</string>
|
<string>VniDrop</string>
|
||||||
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
<!-- macOS: a single instance only; re-launching activates the running app
|
<!-- macOS: a single instance only; re-launching activates the running app
|
||||||
instead of spawning another copy. -->
|
instead of spawning another copy. -->
|
||||||
<key>LSMultipleInstancesProhibited</key>
|
<key>LSMultipleInstancesProhibited</key>
|
||||||
@@ -55,6 +57,20 @@
|
|||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>LSApplicationCategoryType</key>
|
<key>LSApplicationCategoryType</key>
|
||||||
<string>public.app-category.utilities</string>
|
<string>public.app-category.utilities</string>
|
||||||
|
<!-- Sparkle auto-update (direct-download .dmg build only). These keys are inert
|
||||||
|
in the App Store build, which never loads Sparkle (DIRECT_DISTRIBUTION off).
|
||||||
|
The feed is appcast.xml attached as an asset to each GitHub Release; the
|
||||||
|
/releases/latest/download/ path always redirects to the newest (non-
|
||||||
|
prerelease) release's copy, and its <enclosure> points at that same release's
|
||||||
|
.dmg — no GitHub Pages or repo commits needed. SUPublicEDKey must be the EdDSA
|
||||||
|
public key printed by Sparkle's `generate_keys` — replace the placeholder
|
||||||
|
before shipping (see apple/RELEASE-MACOS.md). -->
|
||||||
|
<key>SUFeedURL</key>
|
||||||
|
<string>https://github.com/sudosylabs/vnidrop/releases/latest/download/appcast.xml</string>
|
||||||
|
<key>SUPublicEDKey</key>
|
||||||
|
<string>/vcOgyrhPi3e58yL8M7hZvDCOgsAKyBsQu/7ChAUk1M=</string>
|
||||||
|
<key>SUEnableAutomaticChecks</key>
|
||||||
|
<true/>
|
||||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NFCReaderUsageDescription</key>
|
<key>NFCReaderUsageDescription</key>
|
||||||
@@ -73,8 +89,6 @@
|
|||||||
<false/>
|
<false/>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>fetch</string>
|
|
||||||
<string>processing</string>
|
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UIFileSharingEnabled</key>
|
<key>UIFileSharingEnabled</key>
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<!-- iOS: NFC NDEF reading. -->
|
<!-- iOS: NFC reader formats. The iOS 26 SDK requires TAG here and rejects
|
||||||
|
NDEF at App Store upload (error 90778 "NDEF is disallowed"). Our
|
||||||
|
NFCNDEFReaderSession usage keeps working under the TAG entitlement. -->
|
||||||
<key>com.apple.developer.nfc.readersession.formats</key>
|
<key>com.apple.developer.nfc.readersession.formats</key>
|
||||||
<array>
|
<array>
|
||||||
<string>NDEF</string>
|
<string>TAG</string>
|
||||||
</array>
|
</array>
|
||||||
|
|
||||||
<!-- macOS App Sandbox: user-selected files for share/receive, and network
|
<!-- macOS App Sandbox: user-selected files for share/receive, and network
|
||||||
|
|||||||
15
apple/VniDrop/Resources/VniDropDirect.entitlements
Normal file
15
apple/VniDrop/Resources/VniDropDirect.entitlements
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<!-- Entitlements for the direct-download (Developer ID + notarized) macOS build.
|
||||||
|
|
||||||
|
Deliberately NOT sandboxed: a sandboxed app signed for Developer ID requires a
|
||||||
|
provisioning profile, whereas direct-distribution apps run outside the App
|
||||||
|
Store sandbox by convention. Gatekeeper trust here comes from the hardened
|
||||||
|
runtime (ENABLE_HARDENED_RUNTIME) plus notarization, not the sandbox. The App
|
||||||
|
Store target (VniDrop) keeps VniDrop.entitlements with the sandbox enabled.
|
||||||
|
|
||||||
|
Networking and user file access need no entitlements once unsandboxed. -->
|
||||||
|
<dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -42,7 +42,7 @@ struct ProgressRow: View {
|
|||||||
label.font(.subheadline).lineLimit(1)
|
label.font(.subheadline).lineLimit(1)
|
||||||
Spacer()
|
Spacer()
|
||||||
if let progress {
|
if let progress {
|
||||||
Text("\(Int(progress * 100))%").font(.caption).foregroundStyle(.secondary)
|
Text(verbatim: "\(Int(progress * 100))%").font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let detail {
|
if let detail {
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import VnidropCore
|
|||||||
/// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs.
|
/// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs.
|
||||||
extension Error {
|
extension Error {
|
||||||
func toUiText() -> UiText {
|
func toUiText() -> UiText {
|
||||||
|
if let invitation = self as? InvitationError {
|
||||||
|
return invitation.uiText
|
||||||
|
}
|
||||||
if let vni = self as? VnidropError {
|
if let vni = self as? VnidropError {
|
||||||
switch vni {
|
switch vni {
|
||||||
case .Ticket:
|
case .Ticket:
|
||||||
@@ -40,6 +43,7 @@ extension Error {
|
|||||||
|
|
||||||
/// True when the user intentionally backed out of a flow.
|
/// True when the user intentionally backed out of a flow.
|
||||||
var isUserCancellation: Bool {
|
var isUserCancellation: Bool {
|
||||||
|
if let invitation = self as? InvitationError, case .cancelled = invitation { return true }
|
||||||
if let vni = self as? VnidropError, case .Cancelled = vni { return true }
|
if let vni = self as? VnidropError, case .Cancelled = vni { return true }
|
||||||
let haystack = technicalDetail.lowercased()
|
let haystack = technicalDetail.lowercased()
|
||||||
if haystack.isEmpty {
|
if haystack.isEmpty {
|
||||||
@@ -76,6 +80,72 @@ extension Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Maps each semantic `InvitationError` case to a localized user-facing message.
|
||||||
|
/// This is the sole `InvitationError` → `L10n` boundary: no substring guessing,
|
||||||
|
/// except for `.raw`, whose dynamic payload still falls through `reasonHints`.
|
||||||
|
extension InvitationError {
|
||||||
|
var uiText: UiText {
|
||||||
|
switch self {
|
||||||
|
case .empty:
|
||||||
|
return .resource(L10n.Error.invitationEmpty)
|
||||||
|
case .tooLarge, .unsupportedOperation, .noWindowAvailable,
|
||||||
|
.viewControllerUnavailable, .qrUnavailable, .bugReportingUnavailable, .cancelled:
|
||||||
|
return .resource(L10n.Error.generic)
|
||||||
|
case .invalidEncoding, .invalidInvitationURL:
|
||||||
|
return .resource(L10n.Error.invalidTicket)
|
||||||
|
case .shareEmpty:
|
||||||
|
return .resource(L10n.Error.shareEmpty)
|
||||||
|
case .coreNotInitialized:
|
||||||
|
return .resource(L10n.Error.startingUp)
|
||||||
|
case .filesystemUnavailable:
|
||||||
|
return .resource(L10n.Error.filesystem)
|
||||||
|
case .nfcUnavailable, .nfcFailed:
|
||||||
|
return .resource(L10n.Error.nfc)
|
||||||
|
case .cameraUnavailable:
|
||||||
|
return .resource(L10n.Error.camera)
|
||||||
|
case .selectionFailed:
|
||||||
|
return .resource(L10n.Error.selectionFailed)
|
||||||
|
case .deleteRecordsFailed:
|
||||||
|
return .resource(L10n.Error.repository)
|
||||||
|
case .raw(let reason):
|
||||||
|
return reasonHints(reason) ?? .resource(L10n.Error.generic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps a receiver delivery/refusal reason code to a user-facing message, never
|
||||||
|
/// surfacing the raw core code (e.g. `destination_exists`). Unknown codes fall back
|
||||||
|
/// to the substring hints, then a generic message.
|
||||||
|
func receiverReasonUiText(_ reason: String) -> UiText {
|
||||||
|
switch reason {
|
||||||
|
case "destination_exists":
|
||||||
|
return .resource(L10n.Error.destinationExists)
|
||||||
|
case "filesystem", "filesystem_permission_denied":
|
||||||
|
return .resource(L10n.Error.filesystem)
|
||||||
|
case "permission_denied", "approval-required", "approval-expired",
|
||||||
|
"unknown-transfer", "missing-endpoint-id", "invalid-receipt":
|
||||||
|
return .resource(L10n.Error.permission)
|
||||||
|
case "storage_full":
|
||||||
|
return .resource(L10n.Error.storageFull)
|
||||||
|
case "network":
|
||||||
|
return .resource(L10n.Error.network)
|
||||||
|
case "invalid_ticket":
|
||||||
|
return .resource(L10n.Error.invalidTicket)
|
||||||
|
case "transfer":
|
||||||
|
return .resource(L10n.Error.transfer)
|
||||||
|
case "repository", "repository-error":
|
||||||
|
return .resource(L10n.Error.repository)
|
||||||
|
case "invalid_input":
|
||||||
|
return .resource(L10n.Error.invalidInput)
|
||||||
|
case "initialization":
|
||||||
|
return .resource(L10n.Error.initialization)
|
||||||
|
case "cancelled", "internal":
|
||||||
|
return .resource(L10n.Error.generic)
|
||||||
|
default:
|
||||||
|
return reasonHints(reason) ?? .resource(L10n.Error.generic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func transferUiText(_ reason: String) -> UiText {
|
private func transferUiText(_ reason: String) -> UiText {
|
||||||
let detail = reason.lowercased()
|
let detail = reason.lowercased()
|
||||||
if detail.contains("refused") || detail.contains("denied") || detail.contains("not approved") {
|
if detail.contains("refused") || detail.contains("denied") || detail.contains("not approved") {
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ options:
|
|||||||
macOS: "15.0"
|
macOS: "15.0"
|
||||||
createIntermediateGroups: true
|
createIntermediateGroups: true
|
||||||
|
|
||||||
|
# Build configurations. Declaring `configs` replaces XcodeGen's Debug/Release
|
||||||
|
# defaults, so both are re-listed here. `Release-Direct` is a release-type config
|
||||||
|
# used only by the VniDropDirect (notarized DMG + Sparkle) target; the App Store
|
||||||
|
# `VniDrop` target ships under plain `Release`.
|
||||||
|
configs:
|
||||||
|
Debug: debug
|
||||||
|
Release: release
|
||||||
|
Release-Direct: release
|
||||||
|
|
||||||
# Project-wide build settings (applied to every target/config).
|
# Project-wide build settings (applied to every target/config).
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
@@ -26,27 +35,44 @@ packages:
|
|||||||
SFSafeSymbols:
|
SFSafeSymbols:
|
||||||
url: https://github.com/SFSafeSymbols/SFSafeSymbols
|
url: https://github.com/SFSafeSymbols/SFSafeSymbols
|
||||||
from: "5.3.0"
|
from: "5.3.0"
|
||||||
|
# Sparkle powers in-app auto-updates for the direct-download (.dmg) build only.
|
||||||
|
# It is linked exclusively by the VniDropDirect target — SwiftPM links products
|
||||||
|
# per target, not per config, so keeping it off the App Store target is what
|
||||||
|
# guarantees the store binary never bundles a self-updater (App Store forbids it).
|
||||||
|
Sparkle:
|
||||||
|
url: https://github.com/sparkle-project/Sparkle
|
||||||
|
from: "2.9.4"
|
||||||
|
|
||||||
targets:
|
# Shared definition for the two shipping app targets. `VniDrop` (App Store /
|
||||||
VniDrop:
|
# TestFlight) and `VniDropDirect` (notarized DMG + Sparkle) build the exact same
|
||||||
|
# sources; only their destinations, extra dependencies, and the DIRECT_DISTRIBUTION
|
||||||
|
# compile flag differ (set per target below).
|
||||||
|
targetTemplates:
|
||||||
|
AppBase:
|
||||||
type: application
|
type: application
|
||||||
supportedDestinations: [iOS, macOS]
|
|
||||||
configFiles:
|
configFiles:
|
||||||
Debug: Signing.xcconfig
|
Debug: Signing.xcconfig
|
||||||
Release: Signing.xcconfig
|
Release: Signing.xcconfig
|
||||||
|
Release-Direct: Signing.xcconfig
|
||||||
sources:
|
sources:
|
||||||
- path: VniDrop
|
- path: VniDrop
|
||||||
excludes:
|
excludes:
|
||||||
- "Resources/Info.plist"
|
- "Resources/Info.plist"
|
||||||
- "Resources/VniDrop.entitlements"
|
- "Resources/VniDrop.entitlements"
|
||||||
|
- "Resources/VniDropDirect.entitlements"
|
||||||
- "Resources/**/.DS_Store"
|
- "Resources/**/.DS_Store"
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
|
PRODUCT_NAME: VniDrop
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: com.vnidrop.app
|
PRODUCT_BUNDLE_IDENTIFIER: com.vnidrop.app
|
||||||
MARKETING_VERSION: "0.1.0"
|
MARKETING_VERSION: "0.1.0"
|
||||||
|
# Placeholder only — the real CFBundleVersion is stamped at build time as a
|
||||||
|
# UTC YYMMDD.HHMM timestamp by the "Stamp build number" phase below, so every
|
||||||
|
# build is monotonic and self-describing (shown as "MARKETING_VERSION (build)").
|
||||||
CURRENT_PROJECT_VERSION: "1"
|
CURRENT_PROJECT_VERSION: "1"
|
||||||
GENERATE_INFOPLIST_FILE: NO
|
GENERATE_INFOPLIST_FILE: NO
|
||||||
INFOPLIST_FILE: VniDrop/Resources/Info.plist
|
INFOPLIST_FILE: VniDrop/Resources/Info.plist
|
||||||
|
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
|
||||||
# Mirror the Info.plist identity so Xcode's Identity editor shows it too
|
# Mirror the Info.plist identity so Xcode's Identity editor shows it too
|
||||||
# (the editor reads these build settings, not the manual plist).
|
# (the editor reads these build settings, not the manual plist).
|
||||||
INFOPLIST_KEY_CFBundleDisplayName: VniDrop
|
INFOPLIST_KEY_CFBundleDisplayName: VniDrop
|
||||||
@@ -59,16 +85,78 @@ targets:
|
|||||||
# AccentColor asset mirrors VniDropColors.brandPurple — keep them in sync.
|
# AccentColor asset mirrors VniDropColors.brandPurple — keep them in sync.
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
|
||||||
configs:
|
configs:
|
||||||
debug:
|
|
||||||
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
|
|
||||||
release:
|
release:
|
||||||
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
|
# Produce a dSYM in the archive so symbol upload succeeds.
|
||||||
|
DEBUG_INFORMATION_FORMAT: dwarf-with-dsym
|
||||||
|
release-direct:
|
||||||
|
DEBUG_INFORMATION_FORMAT: dwarf-with-dsym
|
||||||
dependencies:
|
dependencies:
|
||||||
- package: VnidropCore
|
- package: VnidropCore
|
||||||
- package: SFSafeSymbols
|
- package: SFSafeSymbols
|
||||||
- sdk: SystemConfiguration.framework
|
- sdk: SystemConfiguration.framework
|
||||||
- sdk: Security.framework
|
- sdk: Security.framework
|
||||||
- sdk: libresolv.tbd
|
- sdk: libresolv.tbd
|
||||||
|
preBuildScripts:
|
||||||
|
# Enforce the typed-resources convention (see .swiftlint.yml). Required: fails
|
||||||
|
# the build if SwiftLint is missing so the rules can't be silently bypassed.
|
||||||
|
- name: SwiftLint (typed resources)
|
||||||
|
basedOnDependencyAnalysis: false
|
||||||
|
script: |
|
||||||
|
# Xcode runs build phases with a minimal PATH that omits Homebrew, so add
|
||||||
|
# the common Homebrew bin dirs (Apple Silicon + Intel) before resolving it.
|
||||||
|
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
|
||||||
|
if which swiftlint >/dev/null; then
|
||||||
|
swiftlint lint --config "${SRCROOT}/.swiftlint.yml"
|
||||||
|
else
|
||||||
|
echo "error: SwiftLint not installed — run 'brew install swiftlint'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
postBuildScripts:
|
||||||
|
# Stamp CFBundleVersion as a UTC YYMMDD.HHMM timestamp into the built
|
||||||
|
# Info.plist before code signing. Runs for every build (Xcode GUI archive and
|
||||||
|
# CLI alike), so both the App Store and direct-download channels get a
|
||||||
|
# monotonic, meaningful build id. CI/reproducible builds can pin it via the
|
||||||
|
# VNIDROP_BUILD env var. MARKETING_VERSION stays the human X.Y.Z version.
|
||||||
|
- name: Stamp build number (UTC timestamp)
|
||||||
|
basedOnDependencyAnalysis: false
|
||||||
|
script: |
|
||||||
|
build="${VNIDROP_BUILD:-$(date -u +%y%m%d.%H%M)}"
|
||||||
|
plist="${TARGET_BUILD_DIR}/${INFOPLIST_PATH}"
|
||||||
|
if [ -f "$plist" ]; then
|
||||||
|
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $build" "$plist"
|
||||||
|
echo "Stamped CFBundleVersion = $build"
|
||||||
|
else
|
||||||
|
echo "warning: Info.plist not found at $plist; CFBundleVersion not stamped"
|
||||||
|
fi
|
||||||
|
|
||||||
|
targets:
|
||||||
|
# App Store / TestFlight target. iOS + macOS, sandboxed, no self-updater.
|
||||||
|
VniDrop:
|
||||||
|
templates: [AppBase]
|
||||||
|
supportedDestinations: [iOS, macOS]
|
||||||
|
|
||||||
|
# Direct-download macOS target: Developer ID signed, notarized, ships in a .dmg
|
||||||
|
# and self-updates via Sparkle. DIRECT_DISTRIBUTION gates all Sparkle code so the
|
||||||
|
# App Store target above never compiles or links it.
|
||||||
|
VniDropDirect:
|
||||||
|
templates: [AppBase]
|
||||||
|
supportedDestinations: [macOS]
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) DIRECT_DISTRIBUTION"
|
||||||
|
# Notarization requires the hardened runtime.
|
||||||
|
ENABLE_HARDENED_RUNTIME: YES
|
||||||
|
# Non-sandboxed entitlements: a sandboxed Developer ID app needs a
|
||||||
|
# provisioning profile, which direct distribution avoids. (App Store target
|
||||||
|
# keeps VniDrop.entitlements with the sandbox.)
|
||||||
|
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDropDirect.entitlements
|
||||||
|
# The Rust core's macOS slice (vnidrop.xcframework) is arm64-only
|
||||||
|
# (build-core.sh builds aarch64-apple-darwin only), so the direct build is
|
||||||
|
# Apple-Silicon-only. Pin ARCHS so the Release-Direct (universal-by-default)
|
||||||
|
# link doesn't fail looking for x86_64 symbols.
|
||||||
|
ARCHS: arm64
|
||||||
|
dependencies:
|
||||||
|
- package: Sparkle
|
||||||
|
|
||||||
VniDropTests:
|
VniDropTests:
|
||||||
type: bundle.unit-test
|
type: bundle.unit-test
|
||||||
@@ -76,6 +164,7 @@ targets:
|
|||||||
configFiles:
|
configFiles:
|
||||||
Debug: Signing.xcconfig
|
Debug: Signing.xcconfig
|
||||||
Release: Signing.xcconfig
|
Release: Signing.xcconfig
|
||||||
|
Release-Direct: Signing.xcconfig
|
||||||
sources:
|
sources:
|
||||||
- path: Tests
|
- path: Tests
|
||||||
settings:
|
settings:
|
||||||
@@ -97,3 +186,21 @@ schemes:
|
|||||||
config: Debug
|
config: Debug
|
||||||
targets:
|
targets:
|
||||||
- VniDropTests
|
- VniDropTests
|
||||||
|
# TestFlight ships the Release build; make the Archive/Profile actions explicit
|
||||||
|
# so Product → Archive can never pick up a Debug configuration.
|
||||||
|
profile:
|
||||||
|
config: Release
|
||||||
|
archive:
|
||||||
|
config: Release
|
||||||
|
|
||||||
|
# Direct-download build: always the Release-Direct config (Sparkle + notarization).
|
||||||
|
VniDropDirect:
|
||||||
|
build:
|
||||||
|
targets:
|
||||||
|
VniDropDirect: all
|
||||||
|
run:
|
||||||
|
config: Release-Direct
|
||||||
|
profile:
|
||||||
|
config: Release-Direct
|
||||||
|
archive:
|
||||||
|
config: Release-Direct
|
||||||
|
|||||||
19
apple/scripts/ExportOptions-DeveloperID.plist
Normal file
19
apple/scripts/ExportOptions-DeveloperID.plist
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<!-- Export options for the direct-download (Developer ID) macOS build consumed by
|
||||||
|
`xcodebuild -exportArchive` in build-dmg.sh. This produces a Developer
|
||||||
|
ID–signed, hardened-runtime .app suitable for notarization and distribution
|
||||||
|
outside the Mac App Store. The App Store build uses a different flow entirely. -->
|
||||||
|
<dict>
|
||||||
|
<key>method</key>
|
||||||
|
<string>developer-id</string>
|
||||||
|
<key>signingStyle</key>
|
||||||
|
<string>manual</string>
|
||||||
|
<!-- Xcode manages the Developer ID Application certificate lookup from the
|
||||||
|
keychain; the hardened runtime is enabled via ENABLE_HARDENED_RUNTIME in the
|
||||||
|
VniDropDirect target. -->
|
||||||
|
<key>teamID</key>
|
||||||
|
<string>${DEVELOPMENT_TEAM}</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
158
apple/scripts/build-dmg.sh
Executable file
158
apple/scripts/build-dmg.sh
Executable file
@@ -0,0 +1,158 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Builds the direct-download macOS artifact: a Developer ID–signed, notarized
|
||||||
|
# .dmg of the VniDropDirect target (the Sparkle-enabled build). Produces:
|
||||||
|
# - apple/dist/VniDrop-<version>.dmg (signed + stapled when notarizing)
|
||||||
|
#
|
||||||
|
# This is the direct-distribution counterpart to the App Store archive flow; it
|
||||||
|
# never touches the App Store `VniDrop` target. The Rust crate is not modified.
|
||||||
|
#
|
||||||
|
# Usage: apple/scripts/build-dmg.sh [version]
|
||||||
|
# version MAJOR.MINOR.PATCH; defaults to MARKETING_VERSION / the git tag.
|
||||||
|
#
|
||||||
|
# Environment:
|
||||||
|
# DEVELOPER_ID_APP Codesign identity, e.g. "Developer ID Application: … (TEAMID)".
|
||||||
|
# Auto-detected from the keychain when unset.
|
||||||
|
# DEVELOPMENT_TEAM Apple team ID (10 chars). Auto-derived from the identity.
|
||||||
|
# NOTARY_PROFILE Name of a `xcrun notarytool store-credentials` keychain
|
||||||
|
# profile. When set, the DMG is notarized and stapled; when
|
||||||
|
# unset the build still produces a signed DMG and prints the
|
||||||
|
# pending notarization step (useful before creds exist).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
APPLE_DIR="$REPO_ROOT/apple"
|
||||||
|
DIST_DIR="$APPLE_DIR/dist"
|
||||||
|
BUILD_DIR="$APPLE_DIR/.build-dmg"
|
||||||
|
PROJECT="$APPLE_DIR/VniDrop.xcodeproj"
|
||||||
|
SCHEME="VniDropDirect"
|
||||||
|
CONFIG="Release-Direct"
|
||||||
|
APP_NAME="VniDrop"
|
||||||
|
|
||||||
|
# --- Resolve version (arg > git tag > project MARKETING_VERSION) -------------
|
||||||
|
resolve_version() {
|
||||||
|
local v="${1:-}"
|
||||||
|
if [ -z "$v" ] && [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
|
||||||
|
v="${GITHUB_REF_NAME#v}"
|
||||||
|
fi
|
||||||
|
if [ -z "$v" ]; then
|
||||||
|
v="$(sed -nE 's/.*MARKETING_VERSION: "([0-9.]+)".*/\1/p' "$APPLE_DIR/project.yml" | head -1)"
|
||||||
|
fi
|
||||||
|
if [[ ! "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo "version must be MAJOR.MINOR.PATCH (got '$v')" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '%s' "$v"
|
||||||
|
}
|
||||||
|
VERSION="$(resolve_version "${1:-}")"
|
||||||
|
|
||||||
|
# CFBundleVersion is a UTC YYMMDD.HHMM timestamp stamped by the target's
|
||||||
|
# "Stamp build number" build phase. Pin it here (one value for the whole archive)
|
||||||
|
# so the app, DMG, and appcast all agree; Sparkle compares it to order updates.
|
||||||
|
BUILD_NUMBER="$(date -u +%y%m%d.%H%M)"
|
||||||
|
export VNIDROP_BUILD="$BUILD_NUMBER"
|
||||||
|
|
||||||
|
# --- Resolve signing identity ------------------------------------------------
|
||||||
|
if [ -z "${DEVELOPER_ID_APP:-}" ]; then
|
||||||
|
DEVELOPER_ID_APP="$(security find-identity -v -p codesigning 2>/dev/null \
|
||||||
|
| sed -nE 's/.*"(Developer ID Application: [^"]+)".*/\1/p' | head -1)"
|
||||||
|
fi
|
||||||
|
if [ -z "${DEVELOPER_ID_APP:-}" ]; then
|
||||||
|
echo "error: no 'Developer ID Application' identity found in the keychain." >&2
|
||||||
|
echo " Create one in Xcode ▸ Settings ▸ Accounts, or set DEVELOPER_ID_APP." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "${DEVELOPMENT_TEAM:-}" ]; then
|
||||||
|
# The team ID is the 10-char code in the trailing parenthesis of the identity.
|
||||||
|
DEVELOPMENT_TEAM="$(printf '%s' "$DEVELOPER_ID_APP" | sed -nE 's/.*\(([A-Z0-9]{10})\)$/\1/p')"
|
||||||
|
fi
|
||||||
|
echo "==> Direct build v$VERSION (CFBundleVersion $BUILD_NUMBER)"
|
||||||
|
echo " identity: $DEVELOPER_ID_APP"
|
||||||
|
echo " team: ${DEVELOPMENT_TEAM:-<unknown>}"
|
||||||
|
|
||||||
|
# --- Build core + regenerate project ----------------------------------------
|
||||||
|
# Release core needs LTO disabled (workspace thin-LTO miscompiles proc-macros).
|
||||||
|
echo "==> Building Rust core (release)"
|
||||||
|
CARGO_PROFILE_RELEASE_LTO=false "$SCRIPT_DIR/build-core.sh" release
|
||||||
|
echo "==> Regenerating Xcode project"
|
||||||
|
( cd "$APPLE_DIR" && xcodegen generate >/dev/null )
|
||||||
|
|
||||||
|
rm -rf "$BUILD_DIR" && mkdir -p "$BUILD_DIR" "$DIST_DIR"
|
||||||
|
ARCHIVE="$BUILD_DIR/$APP_NAME.xcarchive"
|
||||||
|
EXPORT_DIR="$BUILD_DIR/export"
|
||||||
|
|
||||||
|
# --- Archive + export (Developer ID) ----------------------------------------
|
||||||
|
echo "==> Archiving $SCHEME ($CONFIG)"
|
||||||
|
xcodebuild archive \
|
||||||
|
-project "$PROJECT" \
|
||||||
|
-scheme "$SCHEME" \
|
||||||
|
-configuration "$CONFIG" \
|
||||||
|
-destination 'generic/platform=macOS' \
|
||||||
|
-archivePath "$ARCHIVE" \
|
||||||
|
MARKETING_VERSION="$VERSION" \
|
||||||
|
DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM" \
|
||||||
|
CODE_SIGN_STYLE=Manual \
|
||||||
|
CODE_SIGN_IDENTITY="$DEVELOPER_ID_APP" \
|
||||||
|
| xcbeautify 2>/dev/null || true
|
||||||
|
[ -d "$ARCHIVE" ] || { echo "error: archive failed" >&2; exit 1; }
|
||||||
|
|
||||||
|
echo "==> Exporting Developer ID app"
|
||||||
|
EXPORT_OPTS="$BUILD_DIR/ExportOptions.plist"
|
||||||
|
sed "s/\${DEVELOPMENT_TEAM}/$DEVELOPMENT_TEAM/" \
|
||||||
|
"$SCRIPT_DIR/ExportOptions-DeveloperID.plist" > "$EXPORT_OPTS"
|
||||||
|
xcodebuild -exportArchive \
|
||||||
|
-archivePath "$ARCHIVE" \
|
||||||
|
-exportPath "$EXPORT_DIR" \
|
||||||
|
-exportOptionsPlist "$EXPORT_OPTS"
|
||||||
|
APP="$EXPORT_DIR/$APP_NAME.app"
|
||||||
|
[ -d "$APP" ] || { echo "error: export failed" >&2; exit 1; }
|
||||||
|
|
||||||
|
# --- Build the DMG -----------------------------------------------------------
|
||||||
|
DMG="$DIST_DIR/$APP_NAME-$VERSION.dmg"
|
||||||
|
rm -f "$DMG"
|
||||||
|
STAGING="$BUILD_DIR/dmg-staging"
|
||||||
|
rm -rf "$STAGING" && mkdir -p "$STAGING"
|
||||||
|
cp -R "$APP" "$STAGING/"
|
||||||
|
ln -s /Applications "$STAGING/Applications"
|
||||||
|
|
||||||
|
echo "==> Building DMG"
|
||||||
|
if command -v create-dmg >/dev/null 2>&1; then
|
||||||
|
create-dmg \
|
||||||
|
--volname "$APP_NAME" \
|
||||||
|
--app-drop-link 380 205 \
|
||||||
|
--icon "$APP_NAME.app" 130 205 \
|
||||||
|
--window-size 540 380 \
|
||||||
|
--no-internet-enable \
|
||||||
|
"$DMG" "$STAGING" >/dev/null || {
|
||||||
|
# create-dmg exits non-zero if it can't set the fancy layout; fall back.
|
||||||
|
[ -f "$DMG" ] || hdiutil create -volname "$APP_NAME" -srcfolder "$STAGING" \
|
||||||
|
-ov -format UDZO "$DMG" >/dev/null
|
||||||
|
}
|
||||||
|
else
|
||||||
|
hdiutil create -volname "$APP_NAME" -srcfolder "$STAGING" \
|
||||||
|
-ov -format UDZO "$DMG" >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Signing DMG"
|
||||||
|
codesign --force --sign "$DEVELOPER_ID_APP" --timestamp "$DMG"
|
||||||
|
|
||||||
|
# --- Notarize + staple -------------------------------------------------------
|
||||||
|
if [ -n "${NOTARY_PROFILE:-}" ]; then
|
||||||
|
echo "==> Notarizing (profile: $NOTARY_PROFILE)"
|
||||||
|
xcrun notarytool submit "$DMG" --keychain-profile "$NOTARY_PROFILE" --wait
|
||||||
|
echo "==> Stapling"
|
||||||
|
xcrun stapler staple "$DMG"
|
||||||
|
xcrun stapler validate "$DMG"
|
||||||
|
spctl -a -vvv --type install "$DMG" || true
|
||||||
|
else
|
||||||
|
echo "==> NOTARY_PROFILE unset — skipping notarization."
|
||||||
|
echo " The DMG is signed but NOT notarized; Gatekeeper will block it until"
|
||||||
|
echo " you run 'xcrun notarytool store-credentials' and re-run with NOTARY_PROFILE set."
|
||||||
|
fi
|
||||||
|
|
||||||
|
SIZE="$(stat -f%z "$DMG")"
|
||||||
|
echo "==> Done."
|
||||||
|
echo " dmg: $DMG"
|
||||||
|
echo " version: $VERSION"
|
||||||
|
echo " size: $SIZE bytes"
|
||||||
68
apple/scripts/generate-appcast.sh
Executable file
68
apple/scripts/generate-appcast.sh
Executable file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Generates/updates the Sparkle appcast for the direct-download build. Runs
|
||||||
|
# Sparkle's `generate_appcast` over the DMGs in apple/dist/, writing:
|
||||||
|
# - apple/dist/appcast.xml
|
||||||
|
#
|
||||||
|
# The <enclosure> URLs point at the matching GitHub Release download assets, and
|
||||||
|
# each item is signed with the project's EdDSA key (from a key file or the
|
||||||
|
# keychain). The resulting appcast.xml is uploaded as a release asset; the app's
|
||||||
|
# SUFeedURL (/releases/latest/download/appcast.xml) always resolves to the newest.
|
||||||
|
#
|
||||||
|
# Usage: apple/scripts/generate-appcast.sh [version]
|
||||||
|
#
|
||||||
|
# Environment:
|
||||||
|
# DIST_DIR Folder holding the DMG(s). Default: apple/dist
|
||||||
|
# SPARKLE_BIN Dir containing generate_appcast. Auto-located when unset.
|
||||||
|
# SPARKLE_ED_KEY_FILE Path to the EdDSA private key file. When unset,
|
||||||
|
# generate_appcast reads the key from the login keychain.
|
||||||
|
# RELEASE_REPO owner/repo for enclosure URLs. Default: sudosylabs/vnidrop
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
APPLE_DIR="$REPO_ROOT/apple"
|
||||||
|
DIST_DIR="${DIST_DIR:-$APPLE_DIR/dist}"
|
||||||
|
RELEASE_REPO="${RELEASE_REPO:-sudosylabs/vnidrop}"
|
||||||
|
|
||||||
|
VERSION="${1:-}"
|
||||||
|
if [ -z "$VERSION" ] && [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
fi
|
||||||
|
[ -n "$VERSION" ] || { echo "error: version required (arg or tag)" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Enclosure URLs resolve to the specific release's assets.
|
||||||
|
DOWNLOAD_PREFIX="https://github.com/$RELEASE_REPO/releases/download/v$VERSION"
|
||||||
|
|
||||||
|
# --- Locate generate_appcast -------------------------------------------------
|
||||||
|
find_tool() {
|
||||||
|
local name="$1"
|
||||||
|
if [ -n "${SPARKLE_BIN:-}" ] && [ -x "$SPARKLE_BIN/$name" ]; then
|
||||||
|
printf '%s' "$SPARKLE_BIN/$name"; return 0
|
||||||
|
fi
|
||||||
|
if command -v "$name" >/dev/null 2>&1; then command -v "$name"; return 0; fi
|
||||||
|
# Sparkle SPM artifact bundle lands under DerivedData SourcePackages.
|
||||||
|
local dd="${APPLE_DERIVED_DATA:-$HOME/Library/Developer/Xcode/DerivedData}"
|
||||||
|
local hit
|
||||||
|
hit="$(find "$dd" "$HOME/Library/Caches/org.swift.swiftpm" -type f -name "$name" \
|
||||||
|
-perm -111 2>/dev/null | head -1 || true)"
|
||||||
|
[ -n "$hit" ] && { printf '%s' "$hit"; return 0; }
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
GENERATE_APPCAST="$(find_tool generate_appcast || true)"
|
||||||
|
if [ -z "$GENERATE_APPCAST" ]; then
|
||||||
|
echo "error: generate_appcast not found. Set SPARKLE_BIN to Sparkle's bin/ dir" >&2
|
||||||
|
echo " (download from https://github.com/sparkle-project/Sparkle/releases)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "==> Using $GENERATE_APPCAST"
|
||||||
|
|
||||||
|
# --- Generate ----------------------------------------------------------------
|
||||||
|
args=( --download-url-prefix "$DOWNLOAD_PREFIX/" -o "$DIST_DIR/appcast.xml" )
|
||||||
|
if [ -n "${SPARKLE_ED_KEY_FILE:-}" ]; then
|
||||||
|
args+=( --ed-key-file "$SPARKLE_ED_KEY_FILE" )
|
||||||
|
fi
|
||||||
|
echo "==> Generating appcast (v$VERSION) → $DIST_DIR/appcast.xml"
|
||||||
|
"$GENERATE_APPCAST" "${args[@]}" "$DIST_DIR"
|
||||||
|
|
||||||
|
echo "==> Done. Enclosure prefix: $DOWNLOAD_PREFIX/"
|
||||||
@@ -91,6 +91,11 @@ or temporary tag. Receive downloads keep a temporary tag through export and beco
|
|||||||
reclaimable after publication. Restart reconciliation repairs active-share tags,
|
reclaimable after publication. Restart reconciliation repairs active-share tags,
|
||||||
removes orphan share tags, and never restores a stopped share.
|
removes orphan share tags, and never restores a stopped share.
|
||||||
|
|
||||||
|
The explicit transfer-cache action is available only when no transfer or share is
|
||||||
|
active. It shuts the core down cleanly, removes the app-owned blob store, and then
|
||||||
|
restarts the core with the same identity and network configuration. Deleting all
|
||||||
|
transfer records invokes the same cleanup after live shares have been stopped.
|
||||||
|
|
||||||
## Resource Limits
|
## Resource Limits
|
||||||
|
|
||||||
`CoreLimits` controls source count, collection files and bytes, path and ticket
|
`CoreLimits` controls source count, collection files and bytes, path and ticket
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ blake3 = "1.8.3"
|
|||||||
data-encoding = "2.11.0"
|
data-encoding = "2.11.0"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
futures-lite = "2.6.1"
|
futures-lite = "2.6.1"
|
||||||
iroh = "1.0.0"
|
iroh = "1.0.3"
|
||||||
iroh-blobs = "0.103.0"
|
iroh-blobs = "0.103.0"
|
||||||
irpc = "0.17.0"
|
irpc = "0.17.0"
|
||||||
irpc-iroh = "0.17.0"
|
irpc-iroh = "0.17.0"
|
||||||
@@ -36,4 +36,5 @@ uuid = { version = "1.23.3", features = ["v4", "serde"] }
|
|||||||
walkdir = "2.5.0"
|
walkdir = "2.5.0"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
iroh-relay = { version = "1.0.3", features = ["server"] }
|
||||||
tempfile = "3.27.0"
|
tempfile = "3.27.0"
|
||||||
|
|||||||
@@ -1,9 +1,175 @@
|
|||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
use iroh::RelayUrl;
|
||||||
use iroh_blobs::Hash;
|
use iroh_blobs::Hash;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{collections::BTreeSet, net::IpAddr, path::PathBuf, str::FromStr};
|
||||||
|
|
||||||
|
use crate::error::VnidropError;
|
||||||
use crate::util::{non_empty, now_ms};
|
use crate::util::{non_empty, now_ms};
|
||||||
|
|
||||||
|
pub(crate) const MAX_CUSTOM_RELAYS: usize = 8;
|
||||||
|
pub(crate) const MAX_RELAY_URL_BYTES: usize = 2_048;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
|
||||||
|
pub enum CoreRelayMode {
|
||||||
|
Automatic,
|
||||||
|
StrictCustom,
|
||||||
|
CustomWithDirectFallback,
|
||||||
|
LocalOnly,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
|
||||||
|
pub struct CoreNetworkConfig {
|
||||||
|
pub mode: CoreRelayMode,
|
||||||
|
pub relay_urls: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CoreNetworkConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
mode: CoreRelayMode::Automatic,
|
||||||
|
relay_urls: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CoreNetworkConfig {
|
||||||
|
pub(crate) fn validated_relay_urls(&self) -> anyhow::Result<Vec<RelayUrl>> {
|
||||||
|
match self.mode {
|
||||||
|
CoreRelayMode::Automatic | CoreRelayMode::LocalOnly => {
|
||||||
|
if !self.relay_urls.is_empty() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"{} relay mode must not include custom relay URLs",
|
||||||
|
match self.mode {
|
||||||
|
CoreRelayMode::Automatic => "automatic",
|
||||||
|
CoreRelayMode::LocalOnly => "local-only",
|
||||||
|
CoreRelayMode::StrictCustom
|
||||||
|
| CoreRelayMode::CustomWithDirectFallback => unreachable!(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback => {
|
||||||
|
if self.relay_urls.is_empty() {
|
||||||
|
anyhow::bail!("custom relay mode requires at least one relay URL");
|
||||||
|
}
|
||||||
|
if self.relay_urls.len() > MAX_CUSTOM_RELAYS {
|
||||||
|
anyhow::bail!(
|
||||||
|
"custom relay mode supports at most {MAX_CUSTOM_RELAYS} relay URLs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut seen = BTreeSet::new();
|
||||||
|
let mut validated = Vec::with_capacity(self.relay_urls.len());
|
||||||
|
for (index, value) in self.relay_urls.iter().enumerate() {
|
||||||
|
if value.is_empty()
|
||||||
|
|| value
|
||||||
|
.chars()
|
||||||
|
.any(|character| character.is_whitespace() || character.is_control())
|
||||||
|
{
|
||||||
|
anyhow::bail!(
|
||||||
|
"relay URL must be non-empty and contain no whitespace or control characters"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if value.len() > MAX_RELAY_URL_BYTES {
|
||||||
|
anyhow::bail!(
|
||||||
|
"relay URL is {} bytes, limit is {MAX_RELAY_URL_BYTES}",
|
||||||
|
value.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let url = RelayUrl::from_str(value)
|
||||||
|
.with_context(|| format!("invalid relay URL at position {}", index + 1))?;
|
||||||
|
let secure = url.scheme() == "https";
|
||||||
|
let loopback_http = url.scheme() == "http"
|
||||||
|
&& url.host_str().is_some_and(|host| {
|
||||||
|
host.eq_ignore_ascii_case("localhost")
|
||||||
|
|| host
|
||||||
|
.trim_start_matches('[')
|
||||||
|
.trim_end_matches(']')
|
||||||
|
.parse::<IpAddr>()
|
||||||
|
.is_ok_and(|address| address.is_loopback())
|
||||||
|
});
|
||||||
|
if !secure && !loopback_http {
|
||||||
|
anyhow::bail!(
|
||||||
|
"relay URL must use HTTPS; HTTP is allowed only for loopback development relays"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if url.host_str().is_none() {
|
||||||
|
anyhow::bail!("relay URL must include a host");
|
||||||
|
}
|
||||||
|
if url.port() == Some(0) {
|
||||||
|
anyhow::bail!("relay URL port must be between 1 and 65535");
|
||||||
|
}
|
||||||
|
if value.contains('@') || !url.username().is_empty() || url.password().is_some()
|
||||||
|
{
|
||||||
|
anyhow::bail!("relay URL must not contain credentials");
|
||||||
|
}
|
||||||
|
if url.query().is_some() || url.fragment().is_some() {
|
||||||
|
anyhow::bail!("relay URL must not contain a query or fragment");
|
||||||
|
}
|
||||||
|
if url.path() != "/" {
|
||||||
|
anyhow::bail!("relay URL must not contain a path");
|
||||||
|
}
|
||||||
|
if !seen.insert(url.clone()) {
|
||||||
|
anyhow::bail!("custom relay URLs must not contain duplicates");
|
||||||
|
}
|
||||||
|
validated.push(url);
|
||||||
|
}
|
||||||
|
Ok(validated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn default_core_network_config() -> CoreNetworkConfig {
|
||||||
|
CoreNetworkConfig::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the blob store after its owning core has shut down.
|
||||||
|
///
|
||||||
|
/// Callers must verify that no transfer or share is active before shutdown.
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn clear_inactive_transfer_cache(app_data_dir: String) -> Result<u64, VnidropError> {
|
||||||
|
let result = (|| -> anyhow::Result<u64> {
|
||||||
|
let app_data_dir = PathBuf::from(app_data_dir);
|
||||||
|
anyhow::ensure!(
|
||||||
|
app_data_dir.is_absolute(),
|
||||||
|
"app data directory must be absolute"
|
||||||
|
);
|
||||||
|
anyhow::ensure!(
|
||||||
|
app_data_dir.file_name().is_some(),
|
||||||
|
"app data directory must not be a filesystem root"
|
||||||
|
);
|
||||||
|
let blobs = app_data_dir.join("blobs");
|
||||||
|
let metadata = match std::fs::symlink_metadata(&blobs) {
|
||||||
|
Ok(metadata) => metadata,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
|
anyhow::ensure!(
|
||||||
|
metadata.is_dir() && !metadata.file_type().is_symlink(),
|
||||||
|
"blob store path is not a directory"
|
||||||
|
);
|
||||||
|
let bytes = walkdir::WalkDir::new(&blobs)
|
||||||
|
.follow_links(false)
|
||||||
|
.into_iter()
|
||||||
|
.try_fold(0u64, |total, entry| {
|
||||||
|
let entry = entry?;
|
||||||
|
let metadata = entry.metadata()?;
|
||||||
|
Ok::<_, walkdir::Error>(if metadata.is_file() {
|
||||||
|
total.saturating_add(metadata.len())
|
||||||
|
} else {
|
||||||
|
total
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
std::fs::remove_dir_all(blobs)?;
|
||||||
|
Ok(bytes)
|
||||||
|
})();
|
||||||
|
result.map_err(VnidropError::filesystem)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
||||||
pub struct CoreLimits {
|
pub struct CoreLimits {
|
||||||
pub max_sources: u64,
|
pub max_sources: u64,
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ use uuid::Uuid;
|
|||||||
use crate::{
|
use crate::{
|
||||||
access_policy::{AccessPolicy, APPROVAL_SESSION_TTL_MS},
|
access_policy::{AccessPolicy, APPROVAL_SESSION_TTL_MS},
|
||||||
event_hub::EventHub,
|
event_hub::EventHub,
|
||||||
handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, RequestTransfer},
|
handshake::{
|
||||||
|
DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse,
|
||||||
|
RequestTransfer,
|
||||||
|
},
|
||||||
repository::{ReceiverRequestInsert, Repository},
|
repository::{ReceiverRequestInsert, Repository},
|
||||||
transfer_state::ReceiverRequestStatus,
|
transfer_state::ReceiverRequestStatus,
|
||||||
util::now_ms,
|
util::now_ms,
|
||||||
@@ -70,6 +73,46 @@ impl ApprovalService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn fail_delivery(
|
||||||
|
&self,
|
||||||
|
remote_endpoint_id: String,
|
||||||
|
receipt: DeliveryFailureReceipt,
|
||||||
|
) -> DeliveryReceiptResponse {
|
||||||
|
let token_hash = receipt_token_hash(&receipt.token);
|
||||||
|
match self
|
||||||
|
.repository
|
||||||
|
.fail_receiver_delivery(
|
||||||
|
&receipt.request_id,
|
||||||
|
receipt.transfer_id,
|
||||||
|
&remote_endpoint_id,
|
||||||
|
&token_hash,
|
||||||
|
&receipt.reason,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => {
|
||||||
|
self.event_hub.emit_transfer(
|
||||||
|
receipt.transfer_id,
|
||||||
|
"send",
|
||||||
|
"delivery",
|
||||||
|
"receiver-failed",
|
||||||
|
json!({
|
||||||
|
"request_id": receipt.request_id,
|
||||||
|
"remote_endpoint_id": remote_endpoint_id,
|
||||||
|
"reason": receipt.reason,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
DeliveryReceiptResponse::Recorded
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, "rejected receiver delivery failure");
|
||||||
|
DeliveryReceiptResponse::Rejected {
|
||||||
|
reason: "invalid-receipt".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn new(
|
pub(crate) fn new(
|
||||||
repository: Repository,
|
repository: Repository,
|
||||||
event_hub: Arc<EventHub>,
|
event_hub: Arc<EventHub>,
|
||||||
|
|||||||
@@ -72,6 +72,14 @@ impl ProtocolHandler for HandshakeService {
|
|||||||
.await;
|
.await;
|
||||||
let _ = tx.send(response).await;
|
let _ = tx.send(response).await;
|
||||||
}
|
}
|
||||||
|
HandshakeMessage::ReportDeliveryFailure(message) => {
|
||||||
|
let WithChannels { inner, tx, .. } = message;
|
||||||
|
let response = self
|
||||||
|
.approval
|
||||||
|
.fail_delivery(remote_endpoint_id.clone(), inner)
|
||||||
|
.await;
|
||||||
|
let _ = tx.send(response).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +117,13 @@ impl HandshakeClient {
|
|||||||
) -> Result<DeliveryReceiptResponse, irpc::Error> {
|
) -> Result<DeliveryReceiptResponse, irpc::Error> {
|
||||||
self.inner.rpc(receipt).await
|
self.inner.rpc(receipt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn report_delivery_failure(
|
||||||
|
&self,
|
||||||
|
receipt: DeliveryFailureReceipt,
|
||||||
|
) -> Result<DeliveryReceiptResponse, irpc::Error> {
|
||||||
|
self.inner.rpc(receipt).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -140,6 +155,14 @@ pub(crate) struct DeliveryReceipt {
|
|||||||
pub(crate) token: String,
|
pub(crate) token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct DeliveryFailureReceipt {
|
||||||
|
pub(crate) request_id: String,
|
||||||
|
pub(crate) transfer_id: u64,
|
||||||
|
pub(crate) token: String,
|
||||||
|
pub(crate) reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub(crate) enum DeliveryReceiptResponse {
|
pub(crate) enum DeliveryReceiptResponse {
|
||||||
Recorded,
|
Recorded,
|
||||||
@@ -153,4 +176,6 @@ enum HandshakeProtocol {
|
|||||||
RequestTransfer(RequestTransfer),
|
RequestTransfer(RequestTransfer),
|
||||||
#[rpc(tx=oneshot::Sender<DeliveryReceiptResponse>)]
|
#[rpc(tx=oneshot::Sender<DeliveryReceiptResponse>)]
|
||||||
ReportDelivery(DeliveryReceipt),
|
ReportDelivery(DeliveryReceipt),
|
||||||
|
#[rpc(tx=oneshot::Sender<DeliveryReceiptResponse>)]
|
||||||
|
ReportDeliveryFailure(DeliveryFailureReceipt),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ mod transfer_state;
|
|||||||
mod util;
|
mod util;
|
||||||
|
|
||||||
pub use api::{
|
pub use api::{
|
||||||
default_core_limits, CoreEvent, CoreEventSink, CoreLimits, CoreStorageUsage, PublishedOutput,
|
clear_inactive_transfer_cache, default_core_limits, default_core_network_config, CoreEvent,
|
||||||
|
CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, PublishedOutput,
|
||||||
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest,
|
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest,
|
||||||
RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer,
|
RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer,
|
||||||
TicketInspection, TransferAccessMode, TransferMetadata,
|
TicketInspection, TransferAccessMode, TransferMetadata,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use crate::{
|
|||||||
util::now_ms,
|
util::now_ms,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SCHEMA_VERSION: i64 = 6;
|
const SCHEMA_VERSION: i64 = 7;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct Repository {
|
pub(crate) struct Repository {
|
||||||
@@ -49,6 +49,7 @@ pub(crate) struct PersistedShare {
|
|||||||
pub(crate) transfer_id: u64,
|
pub(crate) transfer_id: u64,
|
||||||
pub(crate) local_id: String,
|
pub(crate) local_id: String,
|
||||||
pub(crate) content_hash: String,
|
pub(crate) content_hash: String,
|
||||||
|
pub(crate) ticket: Option<String>,
|
||||||
pub(crate) access_mode: String,
|
pub(crate) access_mode: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +86,7 @@ pub(crate) struct PendingDeliveryReceipt {
|
|||||||
pub(crate) request_id: String,
|
pub(crate) request_id: String,
|
||||||
pub(crate) sender_transfer_id: u64,
|
pub(crate) sender_transfer_id: u64,
|
||||||
pub(crate) token: String,
|
pub(crate) token: String,
|
||||||
|
pub(crate) failure_reason: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct PendingDeliveryReceiptInsert<'a> {
|
pub(crate) struct PendingDeliveryReceiptInsert<'a> {
|
||||||
@@ -93,6 +95,7 @@ pub(crate) struct PendingDeliveryReceiptInsert<'a> {
|
|||||||
pub(crate) request_id: &'a str,
|
pub(crate) request_id: &'a str,
|
||||||
pub(crate) sender_transfer_id: u64,
|
pub(crate) sender_transfer_id: u64,
|
||||||
pub(crate) token: &'a str,
|
pub(crate) token: &'a str,
|
||||||
|
pub(crate) failure_reason: Option<&'a str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Repository {
|
impl Repository {
|
||||||
@@ -293,12 +296,24 @@ impl Repository {
|
|||||||
sender_blob_ticket TEXT NOT NULL,
|
sender_blob_ticket TEXT NOT NULL,
|
||||||
sender_transfer_id INTEGER NOT NULL,
|
sender_transfer_id INTEGER NOT NULL,
|
||||||
token TEXT NOT NULL,
|
token TEXT NOT NULL,
|
||||||
|
failure_reason TEXT,
|
||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
let receipt_columns = sqlx::query("PRAGMA table_info(pending_delivery_receipts)")
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
if !receipt_columns
|
||||||
|
.iter()
|
||||||
|
.any(|row| row.get::<String, _>(1) == "failure_reason")
|
||||||
|
{
|
||||||
|
sqlx::query("ALTER TABLE pending_delivery_receipts ADD COLUMN failure_reason TEXT")
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}"))
|
sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}"))
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
@@ -560,13 +575,14 @@ impl Repository {
|
|||||||
r#"
|
r#"
|
||||||
INSERT INTO pending_delivery_receipts (
|
INSERT INTO pending_delivery_receipts (
|
||||||
request_id, local_transfer_id, sender_blob_ticket,
|
request_id, local_transfer_id, sender_blob_ticket,
|
||||||
sender_transfer_id, token, created_at
|
sender_transfer_id, token, failure_reason, created_at
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||||
ON CONFLICT(request_id) DO UPDATE SET
|
ON CONFLICT(request_id) DO UPDATE SET
|
||||||
local_transfer_id = excluded.local_transfer_id,
|
local_transfer_id = excluded.local_transfer_id,
|
||||||
sender_blob_ticket = excluded.sender_blob_ticket,
|
sender_blob_ticket = excluded.sender_blob_ticket,
|
||||||
sender_transfer_id = excluded.sender_transfer_id,
|
sender_transfer_id = excluded.sender_transfer_id,
|
||||||
token = excluded.token
|
token = excluded.token,
|
||||||
|
failure_reason = excluded.failure_reason
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(receipt.request_id)
|
.bind(receipt.request_id)
|
||||||
@@ -574,6 +590,7 @@ impl Repository {
|
|||||||
.bind(receipt.sender_blob_ticket)
|
.bind(receipt.sender_blob_ticket)
|
||||||
.bind(to_db_id(receipt.sender_transfer_id)?)
|
.bind(to_db_id(receipt.sender_transfer_id)?)
|
||||||
.bind(receipt.token)
|
.bind(receipt.token)
|
||||||
|
.bind(receipt.failure_reason)
|
||||||
.bind(now_ms())
|
.bind(now_ms())
|
||||||
.execute(&mut *transaction)
|
.execute(&mut *transaction)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -581,13 +598,46 @@ impl Repository {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn queue_failed_delivery_receipt(
|
||||||
|
&self,
|
||||||
|
receipt: PendingDeliveryReceiptInsert<'_>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let Some(reason) = receipt.failure_reason else {
|
||||||
|
anyhow::bail!("failed delivery receipt requires a reason");
|
||||||
|
};
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO pending_delivery_receipts (
|
||||||
|
request_id, local_transfer_id, sender_blob_ticket,
|
||||||
|
sender_transfer_id, token, failure_reason, created_at
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||||
|
ON CONFLICT(request_id) DO UPDATE SET
|
||||||
|
local_transfer_id = excluded.local_transfer_id,
|
||||||
|
sender_blob_ticket = excluded.sender_blob_ticket,
|
||||||
|
sender_transfer_id = excluded.sender_transfer_id,
|
||||||
|
token = excluded.token,
|
||||||
|
failure_reason = excluded.failure_reason
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(receipt.request_id)
|
||||||
|
.bind(to_db_id(receipt.local_transfer_id)?)
|
||||||
|
.bind(receipt.sender_blob_ticket)
|
||||||
|
.bind(to_db_id(receipt.sender_transfer_id)?)
|
||||||
|
.bind(receipt.token)
|
||||||
|
.bind(reason)
|
||||||
|
.bind(now_ms())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn list_pending_delivery_receipts(
|
pub(crate) async fn list_pending_delivery_receipts(
|
||||||
&self,
|
&self,
|
||||||
) -> Result<Vec<PendingDeliveryReceipt>> {
|
) -> Result<Vec<PendingDeliveryReceipt>> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT local_transfer_id, sender_blob_ticket, request_id,
|
SELECT local_transfer_id, sender_blob_ticket, request_id,
|
||||||
sender_transfer_id, token
|
sender_transfer_id, token, failure_reason
|
||||||
FROM pending_delivery_receipts
|
FROM pending_delivery_receipts
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
"#,
|
"#,
|
||||||
@@ -602,6 +652,7 @@ impl Repository {
|
|||||||
request_id: row.get("request_id"),
|
request_id: row.get("request_id"),
|
||||||
sender_transfer_id: row.get::<i64, _>("sender_transfer_id") as u64,
|
sender_transfer_id: row.get::<i64, _>("sender_transfer_id") as u64,
|
||||||
token: row.get("token"),
|
token: row.get("token"),
|
||||||
|
failure_reason: row.get("failure_reason"),
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -709,7 +760,7 @@ impl Repository {
|
|||||||
pub(crate) async fn list_active_shares(&self) -> Result<Vec<PersistedShare>> {
|
pub(crate) async fn list_active_shares(&self) -> Result<Vec<PersistedShare>> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT transfer_id, local_id, content_hash, access_mode
|
SELECT transfer_id, local_id, content_hash, ticket, access_mode
|
||||||
FROM transfers
|
FROM transfers
|
||||||
WHERE direction = 'send'
|
WHERE direction = 'send'
|
||||||
AND status = 'sharing'
|
AND status = 'sharing'
|
||||||
@@ -724,7 +775,8 @@ impl Repository {
|
|||||||
transfer_id: row.get::<i64, _>(0) as u64,
|
transfer_id: row.get::<i64, _>(0) as u64,
|
||||||
local_id: row.get::<String, _>(1),
|
local_id: row.get::<String, _>(1),
|
||||||
content_hash: row.get::<String, _>(2),
|
content_hash: row.get::<String, _>(2),
|
||||||
access_mode: row.get::<String, _>(3),
|
ticket: row.get::<Option<String>, _>(3),
|
||||||
|
access_mode: row.get::<String, _>(4),
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -881,6 +933,56 @@ impl Repository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn fail_receiver_delivery(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
transfer_id: u64,
|
||||||
|
remote_endpoint_id: &str,
|
||||||
|
token_hash: &str,
|
||||||
|
reason: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE receiver_requests
|
||||||
|
SET status = 'failed', reason = ?1
|
||||||
|
WHERE id = ?2 AND transfer_id = ?3 AND remote_endpoint_id = ?4 AND receipt_token_hash = ?5
|
||||||
|
AND status = 'accepted'
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(reason)
|
||||||
|
.bind(id)
|
||||||
|
.bind(to_db_id(transfer_id)?)
|
||||||
|
.bind(remote_endpoint_id)
|
||||||
|
.bind(token_hash)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
if result.rows_affected() == 1 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let already_recorded = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM receiver_requests
|
||||||
|
WHERE id = ?1 AND transfer_id = ?2 AND remote_endpoint_id = ?3
|
||||||
|
AND receipt_token_hash = ?4 AND status = 'failed'
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(to_db_id(transfer_id)?)
|
||||||
|
.bind(remote_endpoint_id)
|
||||||
|
.bind(token_hash)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?
|
||||||
|
.get::<i64, _>(0)
|
||||||
|
!= 0;
|
||||||
|
if already_recorded {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
anyhow::bail!("delivery failure did not match an accepted receiver request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn expire_pending_receiver_requests(&self, reason: &str) -> Result<u64> {
|
pub(crate) async fn expire_pending_receiver_requests(&self, reason: &str) -> Result<u64> {
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
use std::{str::FromStr, sync::Arc, time::Duration};
|
use std::{sync::Arc, time::Duration};
|
||||||
|
|
||||||
use iroh_blobs::ticket::BlobTicket;
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use super::CoreInner;
|
use super::{filter_peer_addr_for_relay_mode, CoreInner};
|
||||||
use crate::{
|
use crate::{
|
||||||
handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeService},
|
handshake::{
|
||||||
|
DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeService,
|
||||||
|
},
|
||||||
repository::PendingDeliveryReceipt,
|
repository::PendingDeliveryReceipt,
|
||||||
|
ticket::parse_persisted_sender_address,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DELIVERY_RECEIPT_TIMEOUT: Duration = Duration::from_secs(5);
|
const DELIVERY_RECEIPT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
@@ -60,8 +62,8 @@ impl CoreInner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn deliver_pending_receipt(&self, pending: PendingDeliveryReceipt) {
|
async fn deliver_pending_receipt(&self, pending: PendingDeliveryReceipt) {
|
||||||
let blob_ticket = match BlobTicket::from_str(&pending.sender_blob_ticket) {
|
let sender_addr = match parse_persisted_sender_address(&pending.sender_blob_ticket) {
|
||||||
Ok(ticket) => ticket,
|
Ok(addr) => addr,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::warn!(%error, request_id = %pending.request_id, "discarded invalid pending delivery receipt");
|
tracing::warn!(%error, request_id = %pending.request_id, "discarded invalid pending delivery receipt");
|
||||||
let _ = self
|
let _ = self
|
||||||
@@ -78,14 +80,47 @@ impl CoreInner {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let client = HandshakeService::client(self.endpoint.clone(), blob_ticket.addr().clone());
|
let sender_addr = match filter_peer_addr_for_relay_mode(
|
||||||
let receipt = DeliveryReceipt {
|
&sender_addr,
|
||||||
request_id: pending.request_id.clone(),
|
self.relay_mode,
|
||||||
transfer_id: pending.sender_transfer_id,
|
&self.custom_relay_urls,
|
||||||
token: pending.token,
|
) {
|
||||||
|
Ok(addr) => addr,
|
||||||
|
Err(error) => {
|
||||||
|
self.emit_transfer(
|
||||||
|
pending.local_transfer_id,
|
||||||
|
"receive",
|
||||||
|
"delivery",
|
||||||
|
"receipt-failed",
|
||||||
|
json!({ "reason": error.to_string() }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
match tokio::time::timeout(DELIVERY_RECEIPT_TIMEOUT, client.report_delivery(receipt)).await
|
let client = HandshakeService::client(self.endpoint.clone(), sender_addr);
|
||||||
{
|
let request_id = pending.request_id.clone();
|
||||||
|
let response = tokio::time::timeout(DELIVERY_RECEIPT_TIMEOUT, async {
|
||||||
|
if let Some(reason) = pending.failure_reason {
|
||||||
|
client
|
||||||
|
.report_delivery_failure(DeliveryFailureReceipt {
|
||||||
|
request_id,
|
||||||
|
transfer_id: pending.sender_transfer_id,
|
||||||
|
token: pending.token,
|
||||||
|
reason,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
client
|
||||||
|
.report_delivery(DeliveryReceipt {
|
||||||
|
request_id,
|
||||||
|
transfer_id: pending.sender_transfer_id,
|
||||||
|
token: pending.token,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match response {
|
||||||
Ok(Ok(DeliveryReceiptResponse::Recorded)) => {
|
Ok(Ok(DeliveryReceiptResponse::Recorded)) => {
|
||||||
if let Err(error) = self
|
if let Err(error) = self
|
||||||
.repository
|
.repository
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ use serde_json::json;
|
|||||||
use super::CoreInner;
|
use super::CoreInner;
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{
|
api::{
|
||||||
CoreEvent, CoreEventSink, CoreLimits, CoreStorageUsage, ReceiveOutputSink,
|
CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreStorageUsage,
|
||||||
ReceiveOutputSinkV2, ReceivedArtifact, ReceiverRequest, RuntimeStatus, ShareMetadataInput,
|
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceiverRequest, RuntimeStatus,
|
||||||
ShareResult, ShareSource, StoredTransfer, TicketInspection, TransferAccessMode,
|
ShareMetadataInput, ShareResult, ShareSource, StoredTransfer, TicketInspection,
|
||||||
|
TransferAccessMode,
|
||||||
},
|
},
|
||||||
error::VnidropError,
|
error::VnidropError,
|
||||||
filesystem::platform_path,
|
filesystem::platform_path,
|
||||||
@@ -42,7 +43,26 @@ impl VnidropCore {
|
|||||||
app_data_dir: String,
|
app_data_dir: String,
|
||||||
event_sink: Arc<dyn CoreEventSink>,
|
event_sink: Arc<dyn CoreEventSink>,
|
||||||
) -> Result<Arc<Self>, VnidropError> {
|
) -> Result<Arc<Self>, VnidropError> {
|
||||||
Self::initialize_with_limits(app_data_dir, event_sink, CoreLimits::default())
|
Self::initialize_with_limits_and_network_config(
|
||||||
|
app_data_dir,
|
||||||
|
event_sink,
|
||||||
|
CoreLimits::default(),
|
||||||
|
CoreNetworkConfig::default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::constructor]
|
||||||
|
pub fn initialize_with_network_config(
|
||||||
|
app_data_dir: String,
|
||||||
|
event_sink: Arc<dyn CoreEventSink>,
|
||||||
|
network_config: CoreNetworkConfig,
|
||||||
|
) -> Result<Arc<Self>, VnidropError> {
|
||||||
|
Self::initialize_with_limits_and_network_config(
|
||||||
|
app_data_dir,
|
||||||
|
event_sink,
|
||||||
|
CoreLimits::default(),
|
||||||
|
network_config,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[uniffi::constructor]
|
#[uniffi::constructor]
|
||||||
@@ -50,15 +70,39 @@ impl VnidropCore {
|
|||||||
app_data_dir: String,
|
app_data_dir: String,
|
||||||
event_sink: Arc<dyn CoreEventSink>,
|
event_sink: Arc<dyn CoreEventSink>,
|
||||||
limits: CoreLimits,
|
limits: CoreLimits,
|
||||||
|
) -> Result<Arc<Self>, VnidropError> {
|
||||||
|
Self::initialize_with_limits_and_network_config(
|
||||||
|
app_data_dir,
|
||||||
|
event_sink,
|
||||||
|
limits,
|
||||||
|
CoreNetworkConfig::default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::constructor]
|
||||||
|
pub fn initialize_with_limits_and_network_config(
|
||||||
|
app_data_dir: String,
|
||||||
|
event_sink: Arc<dyn CoreEventSink>,
|
||||||
|
limits: CoreLimits,
|
||||||
|
network_config: CoreNetworkConfig,
|
||||||
) -> Result<Arc<Self>, VnidropError> {
|
) -> Result<Arc<Self>, VnidropError> {
|
||||||
limits.validate().map_err(VnidropError::initialization)?;
|
limits.validate().map_err(VnidropError::initialization)?;
|
||||||
|
let relay_urls = network_config
|
||||||
|
.validated_relay_urls()
|
||||||
|
.map_err(VnidropError::initialization)?;
|
||||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
.thread_name("vnidrop")
|
.thread_name("vnidrop")
|
||||||
.build()?;
|
.build()?;
|
||||||
let app_data_dir = PathBuf::from(app_data_dir);
|
let app_data_dir = PathBuf::from(app_data_dir);
|
||||||
let inner = runtime
|
let inner = runtime
|
||||||
.block_on(CoreInner::start(app_data_dir, event_sink, limits))
|
.block_on(CoreInner::start(
|
||||||
|
app_data_dir,
|
||||||
|
event_sink,
|
||||||
|
limits,
|
||||||
|
network_config.mode,
|
||||||
|
relay_urls,
|
||||||
|
))
|
||||||
.map_err(VnidropError::initialization)?;
|
.map_err(VnidropError::initialization)?;
|
||||||
Ok(Arc::new(Self { runtime, inner }))
|
Ok(Arc::new(Self { runtime, inner }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ mod share;
|
|||||||
mod storage;
|
mod storage;
|
||||||
|
|
||||||
pub use facade::VnidropCore;
|
pub use facade::VnidropCore;
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) use provider::{consume_request_updates, RequestStreamOutcome};
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, HashSet},
|
collections::{HashMap, HashSet},
|
||||||
@@ -27,7 +29,10 @@ use std::{
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use futures_lite::StreamExt as _;
|
use futures_lite::StreamExt as _;
|
||||||
use iroh::{endpoint::presets, protocol::Router, Endpoint};
|
use iroh::{
|
||||||
|
endpoint::presets, protocol::Router, tls::CaTlsConfig, Endpoint, EndpointAddr, RelayConfig,
|
||||||
|
RelayMap, RelayMode, RelayUrl,
|
||||||
|
};
|
||||||
use iroh_blobs::{
|
use iroh_blobs::{
|
||||||
format::collection::Collection,
|
format::collection::Collection,
|
||||||
provider::events::{EventMask, EventSender},
|
provider::events::{EventMask, EventSender},
|
||||||
@@ -45,16 +50,36 @@ use tokio::{
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
access_policy::{mode_from_storage, AccessPolicy},
|
access_policy::{mode_from_storage, AccessPolicy},
|
||||||
api::{CoreEvent, CoreEventSink, CoreLimits},
|
api::{CoreEvent, CoreEventSink, CoreLimits, CoreRelayMode},
|
||||||
approval::ApprovalService,
|
approval::ApprovalService,
|
||||||
event_hub::EventHub,
|
event_hub::EventHub,
|
||||||
handshake::HandshakeService,
|
handshake::HandshakeService,
|
||||||
logging::init_logging,
|
logging::init_logging,
|
||||||
repository::Repository,
|
repository::Repository,
|
||||||
secret::load_or_create_secret,
|
secret::load_or_create_secret,
|
||||||
|
ticket::ticket_matches_relay_profile,
|
||||||
transfer_state::{TransferDirection, TransferStatus},
|
transfer_state::{TransferDirection, TransferStatus},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RELAY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum RelayStatus {
|
||||||
|
Disabled,
|
||||||
|
Connected,
|
||||||
|
Unreachable,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RelayStatus {
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Disabled => "disabled",
|
||||||
|
Self::Connected => "connected",
|
||||||
|
Self::Unreachable => "unreachable",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Owns the Iroh endpoint, blob store, transfer history, and byte streaming.
|
/// Owns the Iroh endpoint, blob store, transfer history, and byte streaming.
|
||||||
/// Kotlin owns app lifecycle and platform file picking.
|
/// Kotlin owns app lifecycle and platform file picking.
|
||||||
pub(super) struct CoreInner {
|
pub(super) struct CoreInner {
|
||||||
@@ -66,6 +91,8 @@ pub(super) struct CoreInner {
|
|||||||
pub(super) event_hub: Arc<EventHub>,
|
pub(super) event_hub: Arc<EventHub>,
|
||||||
pub(super) approval: ApprovalService,
|
pub(super) approval: ApprovalService,
|
||||||
pub(super) limits: CoreLimits,
|
pub(super) limits: CoreLimits,
|
||||||
|
pub(super) relay_mode: CoreRelayMode,
|
||||||
|
pub(super) custom_relay_urls: Vec<RelayUrl>,
|
||||||
pub(super) transfer_slots: Semaphore,
|
pub(super) transfer_slots: Semaphore,
|
||||||
pub(super) access_policy: Arc<AccessPolicy>,
|
pub(super) access_policy: Arc<AccessPolicy>,
|
||||||
/// Sync mutex so cancel can remove + signal without awaiting (and without
|
/// Sync mutex so cancel can remove + signal without awaiting (and without
|
||||||
@@ -93,6 +120,8 @@ impl CoreInner {
|
|||||||
app_data_dir: PathBuf,
|
app_data_dir: PathBuf,
|
||||||
event_sink: Arc<dyn CoreEventSink>,
|
event_sink: Arc<dyn CoreEventSink>,
|
||||||
limits: CoreLimits,
|
limits: CoreLimits,
|
||||||
|
relay_mode: CoreRelayMode,
|
||||||
|
relay_urls: Vec<RelayUrl>,
|
||||||
) -> Result<Arc<Self>> {
|
) -> Result<Arc<Self>> {
|
||||||
tokio::fs::create_dir_all(&app_data_dir).await?;
|
tokio::fs::create_dir_all(&app_data_dir).await?;
|
||||||
init_logging(&app_data_dir)?;
|
init_logging(&app_data_dir)?;
|
||||||
@@ -105,11 +134,48 @@ impl CoreInner {
|
|||||||
add_protected: None,
|
add_protected: None,
|
||||||
});
|
});
|
||||||
let store = FsStore::load_with_opts(store_root.join("blobs.db"), store_options).await?;
|
let store = FsStore::load_with_opts(store_root.join("blobs.db"), store_options).await?;
|
||||||
let endpoint = Endpoint::builder(presets::N0)
|
let endpoint = match relay_mode {
|
||||||
.secret_key(secret_key)
|
CoreRelayMode::Automatic => {
|
||||||
.bind()
|
Endpoint::builder(presets::N0)
|
||||||
.await?;
|
.secret_key(secret_key)
|
||||||
endpoint.online().await;
|
.bind()
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback => {
|
||||||
|
let relay_map = RelayMap::from_iter(relay_urls.iter().cloned().map(|url| {
|
||||||
|
// Loopback HTTP is a development escape hatch. Without TLS the
|
||||||
|
// relay cannot serve Iroh's QUIC address-discovery endpoint.
|
||||||
|
if url.scheme() == "http" {
|
||||||
|
RelayConfig::new(url, None)
|
||||||
|
} else {
|
||||||
|
RelayConfig::from(url)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
// Minimal leaves address lookup empty, so strict custom mode
|
||||||
|
// cannot silently publish or resolve addresses through N0.
|
||||||
|
Endpoint::builder(presets::Minimal)
|
||||||
|
.relay_mode(RelayMode::Custom(relay_map))
|
||||||
|
.ca_tls_config(CaTlsConfig::embedded())
|
||||||
|
.secret_key(secret_key)
|
||||||
|
.bind()
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
CoreRelayMode::LocalOnly => {
|
||||||
|
Endpoint::builder(presets::Minimal)
|
||||||
|
.relay_mode(RelayMode::Disabled)
|
||||||
|
.secret_key(secret_key)
|
||||||
|
.bind()
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let relay_status =
|
||||||
|
match wait_for_relay(&endpoint, relay_mode, &relay_urls, RELAY_CONNECT_TIMEOUT).await {
|
||||||
|
Ok(status) => status,
|
||||||
|
Err(error) => {
|
||||||
|
endpoint.close().await;
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Provider events are where the sender sees remote readers. The core
|
// Provider events are where the sender sees remote readers. The core
|
||||||
// uses them for send progress and for the current approval gate.
|
// uses them for send progress and for the current approval gate.
|
||||||
@@ -122,6 +188,14 @@ impl CoreInner {
|
|||||||
limits.event_queue_capacity as usize,
|
limits.event_queue_capacity as usize,
|
||||||
limits.max_events,
|
limits.max_events,
|
||||||
));
|
));
|
||||||
|
event_hub.emit_endpoint(
|
||||||
|
"network",
|
||||||
|
"relay-status",
|
||||||
|
json!({
|
||||||
|
"mode": relay_mode_label(relay_mode),
|
||||||
|
"status": relay_status.as_str(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
for recovered in recovered_transfers {
|
for recovered in recovered_transfers {
|
||||||
event_hub.emit_transfer(
|
event_hub.emit_transfer(
|
||||||
recovered.transfer_id,
|
recovered.transfer_id,
|
||||||
@@ -189,6 +263,27 @@ impl CoreInner {
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
let relay_profile_matches = share.ticket.as_deref().is_some_and(|ticket| {
|
||||||
|
ticket_matches_relay_profile(ticket, &limits, relay_mode, &relay_urls)
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
if !relay_profile_matches {
|
||||||
|
repository
|
||||||
|
.transition_transfer_status(
|
||||||
|
transfer_id,
|
||||||
|
TransferStatus::Sharing,
|
||||||
|
TransferStatus::Stopped,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
event_hub.emit_transfer(
|
||||||
|
transfer_id,
|
||||||
|
TransferDirection::Send.as_str(),
|
||||||
|
"recovery",
|
||||||
|
"share-stopped-network-profile-changed",
|
||||||
|
json!({ "reason": "saved ticket does not match the active relay profile" }),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let tag_name = share_tag_name(&share.local_id);
|
let tag_name = share_tag_name(&share.local_id);
|
||||||
store
|
store
|
||||||
.tags()
|
.tags()
|
||||||
@@ -239,6 +334,8 @@ impl CoreInner {
|
|||||||
repository,
|
repository,
|
||||||
event_hub,
|
event_hub,
|
||||||
approval,
|
approval,
|
||||||
|
relay_mode,
|
||||||
|
custom_relay_urls: relay_urls,
|
||||||
transfer_slots: Semaphore::new(limits.max_concurrent_transfers as usize),
|
transfer_slots: Semaphore::new(limits.max_concurrent_transfers as usize),
|
||||||
limits,
|
limits,
|
||||||
access_policy,
|
access_policy,
|
||||||
@@ -309,6 +406,88 @@ impl CoreInner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn filter_peer_addr_for_relay_mode(
|
||||||
|
addr: &EndpointAddr,
|
||||||
|
relay_mode: CoreRelayMode,
|
||||||
|
custom_relay_urls: &[RelayUrl],
|
||||||
|
) -> Result<EndpointAddr> {
|
||||||
|
match relay_mode {
|
||||||
|
CoreRelayMode::Automatic => Ok(addr.clone()),
|
||||||
|
CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback => {
|
||||||
|
let mut filtered = EndpointAddr::new(addr.id);
|
||||||
|
for ip_addr in addr.ip_addrs().copied() {
|
||||||
|
filtered = filtered.with_ip_addr(ip_addr);
|
||||||
|
}
|
||||||
|
for relay_url in addr
|
||||||
|
.relay_urls()
|
||||||
|
.filter(|relay_url| custom_relay_urls.contains(relay_url))
|
||||||
|
.cloned()
|
||||||
|
{
|
||||||
|
filtered = filtered.with_relay_url(relay_url);
|
||||||
|
}
|
||||||
|
if filtered.is_empty() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"invitation has no direct address or relay allowed by strict custom relay mode"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(filtered)
|
||||||
|
}
|
||||||
|
CoreRelayMode::LocalOnly => {
|
||||||
|
let mut filtered = EndpointAddr::new(addr.id);
|
||||||
|
for ip_addr in addr.ip_addrs().copied() {
|
||||||
|
filtered = filtered.with_ip_addr(ip_addr);
|
||||||
|
}
|
||||||
|
if filtered.is_empty() {
|
||||||
|
anyhow::bail!("invitation has no direct address allowed by local-only mode");
|
||||||
|
}
|
||||||
|
Ok(filtered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn wait_for_relay(
|
||||||
|
endpoint: &Endpoint,
|
||||||
|
relay_mode: CoreRelayMode,
|
||||||
|
relay_urls: &[RelayUrl],
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<RelayStatus> {
|
||||||
|
if relay_mode == CoreRelayMode::LocalOnly {
|
||||||
|
return Ok(RelayStatus::Disabled);
|
||||||
|
}
|
||||||
|
if tokio::time::timeout(timeout, endpoint.online())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
match relay_mode {
|
||||||
|
CoreRelayMode::StrictCustom => {
|
||||||
|
let configured_relays = relay_urls
|
||||||
|
.iter()
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
anyhow::bail!(
|
||||||
|
"timed out after {} seconds while connecting to custom relays [{configured_relays}]; verify the URLs, TLS certificates, network access, and relay availability",
|
||||||
|
timeout.as_secs_f32(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
CoreRelayMode::Automatic | CoreRelayMode::CustomWithDirectFallback => {
|
||||||
|
return Ok(RelayStatus::Unreachable);
|
||||||
|
}
|
||||||
|
CoreRelayMode::LocalOnly => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(RelayStatus::Connected)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relay_mode_label(relay_mode: CoreRelayMode) -> &'static str {
|
||||||
|
match relay_mode {
|
||||||
|
CoreRelayMode::Automatic => "automatic",
|
||||||
|
CoreRelayMode::StrictCustom => "strict-custom",
|
||||||
|
CoreRelayMode::CustomWithDirectFallback => "custom-with-direct-fallback",
|
||||||
|
CoreRelayMode::LocalOnly => "local-only",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn share_tag_name(local_id: &str) -> String {
|
pub(super) fn share_tag_name(local_id: &str) -> String {
|
||||||
format!("vnidrop/share/{local_id}")
|
format!("vnidrop/share/{local_id}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,31 @@ use tokio::sync::mpsc;
|
|||||||
use super::CoreInner;
|
use super::CoreInner;
|
||||||
use crate::access_policy::AccessDecision;
|
use crate::access_policy::AccessDecision;
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) enum RequestStreamOutcome {
|
||||||
|
TerminalUpdateReceived,
|
||||||
|
Aborted,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn consume_request_updates(
|
||||||
|
mut rx: irpc::channel::mpsc::Receiver<RequestUpdate>,
|
||||||
|
mut handle_update: impl FnMut(RequestUpdate),
|
||||||
|
) -> RequestStreamOutcome {
|
||||||
|
let mut terminal_update_received = false;
|
||||||
|
while let Ok(Some(update)) = rx.recv().await {
|
||||||
|
terminal_update_received |= matches!(
|
||||||
|
update,
|
||||||
|
RequestUpdate::Completed(_) | RequestUpdate::Aborted(_)
|
||||||
|
);
|
||||||
|
handle_update(update);
|
||||||
|
}
|
||||||
|
if terminal_update_received {
|
||||||
|
RequestStreamOutcome::TerminalUpdateReceived
|
||||||
|
} else {
|
||||||
|
RequestStreamOutcome::Aborted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl CoreInner {
|
impl CoreInner {
|
||||||
pub(super) async fn spawn_provider_event_task(
|
pub(super) async fn spawn_provider_event_task(
|
||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
@@ -302,7 +327,7 @@ impl CoreInner {
|
|||||||
transfer_id: u64,
|
transfer_id: u64,
|
||||||
connection_id: u64,
|
connection_id: u64,
|
||||||
request_id: u64,
|
request_id: u64,
|
||||||
mut rx: irpc::channel::mpsc::Receiver<RequestUpdate>,
|
rx: irpc::channel::mpsc::Receiver<RequestUpdate>,
|
||||||
) {
|
) {
|
||||||
// Request update tasks are tied to individual provider streams. Router
|
// Request update tasks are tied to individual provider streams. Router
|
||||||
// shutdown closes those streams; only the long-lived provider receiver
|
// shutdown closes those streams; only the long-lived provider receiver
|
||||||
@@ -318,35 +343,35 @@ impl CoreInner {
|
|||||||
.cloned();
|
.cloned();
|
||||||
let core = self.clone();
|
let core = self.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Ok(Some(update)) = rx.recv().await {
|
let outcome = consume_request_updates(rx, |update| match update {
|
||||||
match update {
|
RequestUpdate::Started(started) => core.emit_transfer(
|
||||||
RequestUpdate::Started(started) => core.emit_transfer(
|
transfer_id,
|
||||||
transfer_id,
|
"send",
|
||||||
"send",
|
"transfer",
|
||||||
"transfer",
|
"started",
|
||||||
"started",
|
json!({
|
||||||
json!({
|
"connection_id": connection_id,
|
||||||
"connection_id": connection_id,
|
"request_id": request_id,
|
||||||
"request_id": request_id,
|
"endpoint_id": endpoint_id,
|
||||||
"endpoint_id": endpoint_id,
|
"hash": started.hash.to_string(),
|
||||||
"hash": started.hash.to_string(),
|
"size": started.size,
|
||||||
"size": started.size,
|
"index": started.index,
|
||||||
"index": started.index,
|
}),
|
||||||
}),
|
),
|
||||||
),
|
RequestUpdate::Progress(progress) => core.emit_transfer(
|
||||||
RequestUpdate::Progress(progress) => core.emit_transfer(
|
transfer_id,
|
||||||
transfer_id,
|
"send",
|
||||||
"send",
|
"transfer",
|
||||||
"transfer",
|
"progress",
|
||||||
"progress",
|
json!({
|
||||||
json!({
|
"connection_id": connection_id,
|
||||||
"connection_id": connection_id,
|
"request_id": request_id,
|
||||||
"request_id": request_id,
|
"endpoint_id": endpoint_id,
|
||||||
"endpoint_id": endpoint_id,
|
"end_offset": progress.end_offset,
|
||||||
"end_offset": progress.end_offset,
|
}),
|
||||||
}),
|
),
|
||||||
),
|
RequestUpdate::Completed(_) => {
|
||||||
RequestUpdate::Completed(_) => core.emit_transfer(
|
core.emit_transfer(
|
||||||
transfer_id,
|
transfer_id,
|
||||||
"send",
|
"send",
|
||||||
"transfer",
|
"transfer",
|
||||||
@@ -356,8 +381,10 @@ impl CoreInner {
|
|||||||
"request_id": request_id,
|
"request_id": request_id,
|
||||||
"endpoint_id": endpoint_id,
|
"endpoint_id": endpoint_id,
|
||||||
}),
|
}),
|
||||||
),
|
);
|
||||||
RequestUpdate::Aborted(_) => core.emit_transfer(
|
}
|
||||||
|
RequestUpdate::Aborted(_) => {
|
||||||
|
core.emit_transfer(
|
||||||
transfer_id,
|
transfer_id,
|
||||||
"send",
|
"send",
|
||||||
"transfer",
|
"transfer",
|
||||||
@@ -367,8 +394,22 @@ impl CoreInner {
|
|||||||
"request_id": request_id,
|
"request_id": request_id,
|
||||||
"endpoint_id": endpoint_id,
|
"endpoint_id": endpoint_id,
|
||||||
}),
|
}),
|
||||||
),
|
);
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
if outcome == RequestStreamOutcome::Aborted {
|
||||||
|
core.emit_transfer(
|
||||||
|
transfer_id,
|
||||||
|
"send",
|
||||||
|
"transfer",
|
||||||
|
"aborted",
|
||||||
|
json!({
|
||||||
|
"connection_id": connection_id,
|
||||||
|
"request_id": request_id,
|
||||||
|
"endpoint_id": endpoint_id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::{
|
use std::{
|
||||||
io,
|
io,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::Arc,
|
sync::{Arc, Mutex},
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@@ -9,12 +9,12 @@ use bytes::Bytes;
|
|||||||
use futures_lite::StreamExt as _;
|
use futures_lite::StreamExt as _;
|
||||||
use iroh_blobs::{
|
use iroh_blobs::{
|
||||||
api::proto::ExportRangesItem, api::remote::GetProgressItem, format::collection::Collection,
|
api::proto::ExportRangesItem, api::remote::GetProgressItem, format::collection::Collection,
|
||||||
get::request::get_hash_seq_and_sizes, Hash,
|
get::request::get_hash_seq_and_sizes, ticket::BlobTicket, Hash,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
use super::{ActiveTransfer, CoreInner};
|
use super::{filter_peer_addr_for_relay_mode, ActiveTransfer, CoreInner};
|
||||||
use crate::{
|
use crate::{
|
||||||
access_policy::mode_to_storage,
|
access_policy::mode_to_storage,
|
||||||
api::{
|
api::{
|
||||||
@@ -28,7 +28,9 @@ use crate::{
|
|||||||
},
|
},
|
||||||
handshake::{DeliveryReceipt, HandshakeResponse, HandshakeService},
|
handshake::{DeliveryReceipt, HandshakeResponse, HandshakeService},
|
||||||
repository::{PendingDeliveryReceiptInsert, ReceivedArtifactInsert, TransferUpsert},
|
repository::{PendingDeliveryReceiptInsert, ReceivedArtifactInsert, TransferUpsert},
|
||||||
ticket::{parse_transfer_ticket_with_limits, ParsedTransferTicket},
|
ticket::{
|
||||||
|
encode_persisted_sender_address, parse_transfer_ticket_with_limits, ParsedTransferTicket,
|
||||||
|
},
|
||||||
transfer_state::{TransferDirection, TransferStatus},
|
transfer_state::{TransferDirection, TransferStatus},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -192,7 +194,7 @@ impl CoreInner {
|
|||||||
.await
|
.await
|
||||||
.context("transfer limiter is closed")
|
.context("transfer limiter is closed")
|
||||||
.map_err(VnidropError::internal)?;
|
.map_err(VnidropError::internal)?;
|
||||||
let parsed = match parse_transfer_ticket_with_limits(&ticket, &self.limits)
|
let mut parsed = match parse_transfer_ticket_with_limits(&ticket, &self.limits)
|
||||||
.context("failed to parse transfer ticket")
|
.context("failed to parse transfer ticket")
|
||||||
{
|
{
|
||||||
Ok(parsed) => parsed,
|
Ok(parsed) => parsed,
|
||||||
@@ -205,10 +207,25 @@ impl CoreInner {
|
|||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let sender_addr = filter_peer_addr_for_relay_mode(
|
||||||
|
parsed.blob_ticket.addr(),
|
||||||
|
self.relay_mode,
|
||||||
|
&self.custom_relay_urls,
|
||||||
|
)
|
||||||
|
.map_err(VnidropError::network)?;
|
||||||
|
parsed.blob_ticket = BlobTicket::new(
|
||||||
|
sender_addr,
|
||||||
|
parsed.blob_ticket.hash(),
|
||||||
|
parsed.blob_ticket.format(),
|
||||||
|
);
|
||||||
let transfer_id = parsed.metadata.transfer_id;
|
let transfer_id = parsed.metadata.transfer_id;
|
||||||
self.persist_receive_start(transfer_id, &parsed, receiver_name.as_deref())
|
self.persist_receive_start(transfer_id, &parsed, receiver_name.as_deref())
|
||||||
.await
|
.await
|
||||||
.map_err(VnidropError::repository)?;
|
.map_err(VnidropError::repository)?;
|
||||||
|
let persisted_sender_address =
|
||||||
|
encode_persisted_sender_address(parsed.blob_ticket.addr())
|
||||||
|
.context("failed to encode sender address for delivery receipt")?;
|
||||||
|
let delivery_receipt = Arc::new(Mutex::new(None));
|
||||||
// Cancellation is cooperative: it stops our receive future and marks
|
// Cancellation is cooperative: it stops our receive future and marks
|
||||||
// local state while lower-level Iroh work unwinds naturally.
|
// local state while lower-level Iroh work unwinds naturally.
|
||||||
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
|
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
|
||||||
@@ -224,7 +241,14 @@ impl CoreInner {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let (result, cancelled) = tokio::select! {
|
let (result, cancelled) = tokio::select! {
|
||||||
result = self.receive_inner(transfer_id, parsed, target, receiver_name) => {
|
result = self.receive_inner(
|
||||||
|
transfer_id,
|
||||||
|
parsed,
|
||||||
|
target,
|
||||||
|
receiver_name,
|
||||||
|
persisted_sender_address.clone(),
|
||||||
|
delivery_receipt.clone(),
|
||||||
|
) => {
|
||||||
(result.map_err(VnidropError::transfer), false)
|
(result.map_err(VnidropError::transfer), false)
|
||||||
},
|
},
|
||||||
_ = &mut shutdown_rx => (Err(VnidropError::cancelled("transfer cancelled")), true),
|
_ = &mut shutdown_rx => (Err(VnidropError::cancelled("transfer cancelled")), true),
|
||||||
@@ -254,6 +278,34 @@ impl CoreInner {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
let receipt = delivery_receipt.lock().expect("delivery_receipt").take();
|
||||||
|
if let Some(receipt) = receipt {
|
||||||
|
let reason = if cancelled { "cancelled" } else { error.code() };
|
||||||
|
match self
|
||||||
|
.repository
|
||||||
|
.queue_failed_delivery_receipt(PendingDeliveryReceiptInsert {
|
||||||
|
local_transfer_id: transfer_id,
|
||||||
|
sender_blob_ticket: &persisted_sender_address,
|
||||||
|
request_id: &receipt.request_id,
|
||||||
|
sender_transfer_id: receipt.transfer_id,
|
||||||
|
token: &receipt.token,
|
||||||
|
failure_reason: Some(reason),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => self.delivery_receipt_notify.notify_one(),
|
||||||
|
Err(queue_error) => {
|
||||||
|
tracing::warn!(%queue_error, "failed to queue delivery failure receipt");
|
||||||
|
self.emit_transfer(
|
||||||
|
transfer_id,
|
||||||
|
"receive",
|
||||||
|
"delivery",
|
||||||
|
"receipt-failed",
|
||||||
|
json!({ "reason": queue_error.to_string() }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
result.map_err(anyhow::Error::new)
|
result.map_err(anyhow::Error::new)
|
||||||
}
|
}
|
||||||
@@ -264,6 +316,8 @@ impl CoreInner {
|
|||||||
parsed: ParsedTransferTicket,
|
parsed: ParsedTransferTicket,
|
||||||
target: ReceiveTarget,
|
target: ReceiveTarget,
|
||||||
receiver_name: Option<String>,
|
receiver_name: Option<String>,
|
||||||
|
persisted_sender_address: String,
|
||||||
|
pending_delivery_receipt: Arc<Mutex<Option<DeliveryReceipt>>>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if let ReceiveTarget::Directory(output_dir) = &target {
|
if let ReceiveTarget::Directory(output_dir) = &target {
|
||||||
tokio::fs::create_dir_all(output_dir)
|
tokio::fs::create_dir_all(output_dir)
|
||||||
@@ -271,7 +325,6 @@ impl CoreInner {
|
|||||||
.map_err(VnidropError::filesystem)?;
|
.map_err(VnidropError::filesystem)?;
|
||||||
}
|
}
|
||||||
let sender_addr = parsed.blob_ticket.addr().clone();
|
let sender_addr = parsed.blob_ticket.addr().clone();
|
||||||
let sender_blob_ticket = parsed.blob_ticket.to_string();
|
|
||||||
|
|
||||||
self.emit_transfer(transfer_id, "receive", "network", "connecting", json!({}));
|
self.emit_transfer(transfer_id, "receive", "network", "connecting", json!({}));
|
||||||
// Every VniDrop ticket carries metadata and must complete the handshake.
|
// Every VniDrop ticket carries metadata and must complete the handshake.
|
||||||
@@ -283,6 +336,9 @@ impl CoreInner {
|
|||||||
receiver_name.as_deref(),
|
receiver_name.as_deref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
*pending_delivery_receipt
|
||||||
|
.lock()
|
||||||
|
.expect("pending_delivery_receipt") = Some(delivery_receipt.clone());
|
||||||
let connection = self
|
let connection = self
|
||||||
.endpoint
|
.endpoint
|
||||||
.connect(sender_addr.clone(), iroh_blobs::ALPN)
|
.connect(sender_addr.clone(), iroh_blobs::ALPN)
|
||||||
@@ -355,13 +411,18 @@ impl CoreInner {
|
|||||||
self.repository
|
self.repository
|
||||||
.complete_receive_with_pending_receipt(PendingDeliveryReceiptInsert {
|
.complete_receive_with_pending_receipt(PendingDeliveryReceiptInsert {
|
||||||
local_transfer_id: transfer_id,
|
local_transfer_id: transfer_id,
|
||||||
sender_blob_ticket: &sender_blob_ticket,
|
sender_blob_ticket: &persisted_sender_address,
|
||||||
request_id: &delivery_receipt.request_id,
|
request_id: &delivery_receipt.request_id,
|
||||||
sender_transfer_id: delivery_receipt.transfer_id,
|
sender_transfer_id: delivery_receipt.transfer_id,
|
||||||
token: &delivery_receipt.token,
|
token: &delivery_receipt.token,
|
||||||
|
failure_reason: None,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(VnidropError::repository)?;
|
.map_err(VnidropError::repository)?;
|
||||||
|
pending_delivery_receipt
|
||||||
|
.lock()
|
||||||
|
.expect("pending_delivery_receipt")
|
||||||
|
.take();
|
||||||
drop(download_tag);
|
drop(download_tag);
|
||||||
self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({}));
|
self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({}));
|
||||||
self.delivery_receipt_notify.notify_one();
|
self.delivery_receipt_notify.notify_one();
|
||||||
|
|||||||
@@ -149,9 +149,13 @@ impl CoreInner {
|
|||||||
import.file_count,
|
import.file_count,
|
||||||
import.total_size,
|
import.total_size,
|
||||||
);
|
);
|
||||||
let ticket = VnidropTicket::new(blob_ticket, ticket_metadata)
|
let ticket = VnidropTicket::new_with_relay_urls(
|
||||||
.encode()
|
blob_ticket,
|
||||||
.context("failed to encode VniDrop transfer ticket")?;
|
ticket_metadata,
|
||||||
|
&self.custom_relay_urls,
|
||||||
|
)
|
||||||
|
.encode()
|
||||||
|
.context("failed to encode VniDrop transfer ticket")?;
|
||||||
let content_hash = import.root_hash.to_string();
|
let content_hash = import.root_hash.to_string();
|
||||||
let local_id = self
|
let local_id = self
|
||||||
.repository
|
.repository
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ mod filesystem_tests;
|
|||||||
mod handshake_tests;
|
mod handshake_tests;
|
||||||
#[path = "tests/limits.rs"]
|
#[path = "tests/limits.rs"]
|
||||||
mod limits_tests;
|
mod limits_tests;
|
||||||
|
#[path = "tests/network_config.rs"]
|
||||||
|
mod network_config_tests;
|
||||||
#[path = "tests/repository.rs"]
|
#[path = "tests/repository.rs"]
|
||||||
mod repository_tests;
|
mod repository_tests;
|
||||||
#[path = "tests/runtime.rs"]
|
#[path = "tests/runtime.rs"]
|
||||||
|
|||||||
232
crates/vnidrop/src/tests/network_config.rs
Normal file
232
crates/vnidrop/src/tests/network_config.rs
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use iroh::{endpoint::presets, Endpoint, EndpointAddr, RelayMode, RelayUrl, SecretKey};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::{
|
||||||
|
default_core_network_config, CoreNetworkConfig, CoreRelayMode, MAX_CUSTOM_RELAYS,
|
||||||
|
MAX_RELAY_URL_BYTES,
|
||||||
|
},
|
||||||
|
runtime::{filter_peer_addr_for_relay_mode, wait_for_relay, RelayStatus},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_network_config_uses_automatic_relays() {
|
||||||
|
assert_eq!(
|
||||||
|
default_core_network_config(),
|
||||||
|
CoreNetworkConfig {
|
||||||
|
mode: CoreRelayMode::Automatic,
|
||||||
|
relay_urls: Vec::new(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
default_core_network_config()
|
||||||
|
.validated_relay_urls()
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relay_mode_and_url_list_must_be_consistent() {
|
||||||
|
let automatic_with_url = CoreNetworkConfig {
|
||||||
|
mode: CoreRelayMode::Automatic,
|
||||||
|
relay_urls: vec!["https://relay.example.com".to_string()],
|
||||||
|
};
|
||||||
|
assert!(automatic_with_url.validated_relay_urls().is_err());
|
||||||
|
|
||||||
|
for mode in [
|
||||||
|
CoreRelayMode::StrictCustom,
|
||||||
|
CoreRelayMode::CustomWithDirectFallback,
|
||||||
|
] {
|
||||||
|
let custom_without_url = CoreNetworkConfig {
|
||||||
|
mode,
|
||||||
|
relay_urls: Vec::new(),
|
||||||
|
};
|
||||||
|
assert!(custom_without_url.validated_relay_urls().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
let local_only_with_url = CoreNetworkConfig {
|
||||||
|
mode: CoreRelayMode::LocalOnly,
|
||||||
|
relay_urls: vec!["https://relay.example.com".to_string()],
|
||||||
|
};
|
||||||
|
assert!(local_only_with_url.validated_relay_urls().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_relay_urls_allow_https_and_loopback_http() {
|
||||||
|
let config = CoreNetworkConfig {
|
||||||
|
mode: CoreRelayMode::StrictCustom,
|
||||||
|
relay_urls: vec![
|
||||||
|
"https://relay.example.com".to_string(),
|
||||||
|
"http://localhost:3340".to_string(),
|
||||||
|
"http://127.0.0.1:3341".to_string(),
|
||||||
|
"http://[::1]:3342".to_string(),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(config.validated_relay_urls().unwrap().len(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_relay_urls_reject_unsafe_or_ambiguous_values() {
|
||||||
|
for value in [
|
||||||
|
"http://relay.example.com",
|
||||||
|
"https://user:password@relay.example.com",
|
||||||
|
"https://relay.example.com/path",
|
||||||
|
"https://relay.example.com?token=secret",
|
||||||
|
"https://relay.example.com#fragment",
|
||||||
|
"https://relay.example.com:0",
|
||||||
|
"https://relay.exa\tmple.com",
|
||||||
|
"https://@relay.example.com",
|
||||||
|
" https://relay.example.com",
|
||||||
|
] {
|
||||||
|
let config = CoreNetworkConfig {
|
||||||
|
mode: CoreRelayMode::StrictCustom,
|
||||||
|
relay_urls: vec![value.to_string()],
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
config.validated_relay_urls().is_err(),
|
||||||
|
"{value} should be rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_relay_urls_are_bounded_and_unique_after_normalization() {
|
||||||
|
let duplicates = CoreNetworkConfig {
|
||||||
|
mode: CoreRelayMode::StrictCustom,
|
||||||
|
relay_urls: vec![
|
||||||
|
"https://relay.example.com".to_string(),
|
||||||
|
"https://relay.example.com/".to_string(),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert!(duplicates.validated_relay_urls().is_err());
|
||||||
|
|
||||||
|
let too_many = CoreNetworkConfig {
|
||||||
|
mode: CoreRelayMode::StrictCustom,
|
||||||
|
relay_urls: (0..=MAX_CUSTOM_RELAYS)
|
||||||
|
.map(|index| format!("https://relay-{index}.example.com"))
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
assert!(too_many.validated_relay_urls().is_err());
|
||||||
|
|
||||||
|
let too_long = CoreNetworkConfig {
|
||||||
|
mode: CoreRelayMode::StrictCustom,
|
||||||
|
relay_urls: vec![format!(
|
||||||
|
"https://{}.example.com",
|
||||||
|
"a".repeat(MAX_RELAY_URL_BYTES)
|
||||||
|
)],
|
||||||
|
};
|
||||||
|
assert!(too_long.validated_relay_urls().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strict_custom_mode_filters_peer_relays_but_retains_direct_addresses() {
|
||||||
|
let allowed: RelayUrl = "https://allowed.relay.example.com".parse().unwrap();
|
||||||
|
let disallowed: RelayUrl = "https://disallowed.relay.example.com".parse().unwrap();
|
||||||
|
let direct = "192.0.2.1:4433".parse().unwrap();
|
||||||
|
let addr = EndpointAddr::new(SecretKey::generate().public())
|
||||||
|
.with_relay_url(allowed.clone())
|
||||||
|
.with_relay_url(disallowed.clone())
|
||||||
|
.with_ip_addr(direct);
|
||||||
|
|
||||||
|
let filtered = filter_peer_addr_for_relay_mode(
|
||||||
|
&addr,
|
||||||
|
CoreRelayMode::StrictCustom,
|
||||||
|
std::slice::from_ref(&allowed),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
filtered.relay_urls().cloned().collect::<Vec<_>>(),
|
||||||
|
vec![allowed.clone()]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
filtered.ip_addrs().copied().collect::<Vec<_>>(),
|
||||||
|
vec![direct]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
filter_peer_addr_for_relay_mode(&addr, CoreRelayMode::Automatic, &[]).unwrap(),
|
||||||
|
addr
|
||||||
|
);
|
||||||
|
|
||||||
|
let disallowed_only =
|
||||||
|
EndpointAddr::new(SecretKey::generate().public()).with_relay_url(disallowed);
|
||||||
|
assert!(filter_peer_addr_for_relay_mode(
|
||||||
|
&disallowed_only,
|
||||||
|
CoreRelayMode::StrictCustom,
|
||||||
|
std::slice::from_ref(&allowed),
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
|
||||||
|
let fallback_filtered = filter_peer_addr_for_relay_mode(
|
||||||
|
&addr,
|
||||||
|
CoreRelayMode::CustomWithDirectFallback,
|
||||||
|
std::slice::from_ref(&allowed),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(fallback_filtered, filtered);
|
||||||
|
|
||||||
|
let local_only = filter_peer_addr_for_relay_mode(&addr, CoreRelayMode::LocalOnly, &[]).unwrap();
|
||||||
|
assert_eq!(local_only.relay_urls().count(), 0);
|
||||||
|
assert_eq!(
|
||||||
|
local_only.ip_addrs().copied().collect::<Vec<_>>(),
|
||||||
|
vec![direct]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
filter_peer_addr_for_relay_mode(&disallowed_only, CoreRelayMode::LocalOnly, &[]).is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn relay_wait_enforces_only_strict_custom_mode() {
|
||||||
|
let relay_url: RelayUrl = "http://127.0.0.1:9".parse().unwrap();
|
||||||
|
let endpoint = Endpoint::builder(presets::Minimal)
|
||||||
|
.relay_mode(RelayMode::custom([relay_url.clone()]))
|
||||||
|
.bind()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let started = Instant::now();
|
||||||
|
|
||||||
|
let error = wait_for_relay(
|
||||||
|
&endpoint,
|
||||||
|
CoreRelayMode::StrictCustom,
|
||||||
|
std::slice::from_ref(&relay_url),
|
||||||
|
Duration::from_millis(50),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(started.elapsed() < Duration::from_secs(1));
|
||||||
|
assert!(error.to_string().contains(relay_url.as_str()));
|
||||||
|
assert!(error.to_string().contains("verify the URLs"));
|
||||||
|
|
||||||
|
let automatic_status = wait_for_relay(
|
||||||
|
&endpoint,
|
||||||
|
CoreRelayMode::Automatic,
|
||||||
|
&[],
|
||||||
|
Duration::from_millis(50),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(started.elapsed() < Duration::from_secs(1));
|
||||||
|
assert_eq!(automatic_status, RelayStatus::Unreachable);
|
||||||
|
|
||||||
|
let fallback_status = wait_for_relay(
|
||||||
|
&endpoint,
|
||||||
|
CoreRelayMode::CustomWithDirectFallback,
|
||||||
|
std::slice::from_ref(&relay_url),
|
||||||
|
Duration::from_millis(50),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(fallback_status, RelayStatus::Unreachable);
|
||||||
|
|
||||||
|
let local_only_status = wait_for_relay(
|
||||||
|
&endpoint,
|
||||||
|
CoreRelayMode::LocalOnly,
|
||||||
|
&[],
|
||||||
|
Duration::from_millis(50),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(local_only_status, RelayStatus::Disabled);
|
||||||
|
endpoint.close().await;
|
||||||
|
}
|
||||||
@@ -66,7 +66,7 @@ async fn received_artifacts_survive_history_deletion() {
|
|||||||
async fn persists_transfers_and_events_across_reopen() {
|
async fn persists_transfers_and_events_across_reopen() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let repository = Repository::open(temp.path()).await.unwrap();
|
let repository = Repository::open(temp.path()).await.unwrap();
|
||||||
assert_eq!(repository.schema_version().await.unwrap(), 6);
|
assert_eq!(repository.schema_version().await.unwrap(), 7);
|
||||||
repository
|
repository
|
||||||
.insert_transfer(transfer(
|
.insert_transfer(transfer(
|
||||||
7,
|
7,
|
||||||
@@ -80,6 +80,7 @@ async fn persists_transfers_and_events_across_reopen() {
|
|||||||
assert_eq!(shares.len(), 1);
|
assert_eq!(shares.len(), 1);
|
||||||
assert_eq!(shares[0].transfer_id, 7);
|
assert_eq!(shares[0].transfer_id, 7);
|
||||||
assert_eq!(shares[0].content_hash, "hash");
|
assert_eq!(shares[0].content_hash, "hash");
|
||||||
|
assert_eq!(shares[0].ticket.as_deref(), Some("ticket"));
|
||||||
assert_eq!(shares[0].access_mode, "approval_required");
|
assert_eq!(shares[0].access_mode, "approval_required");
|
||||||
|
|
||||||
repository
|
repository
|
||||||
@@ -134,6 +135,7 @@ async fn receive_completion_persists_delivery_receipt_until_recorded() {
|
|||||||
request_id: "request-93",
|
request_id: "request-93",
|
||||||
sender_transfer_id: 39,
|
sender_transfer_id: 39,
|
||||||
token: "receipt-token",
|
token: "receipt-token",
|
||||||
|
failure_reason: None,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -220,6 +222,68 @@ async fn receiver_request_can_only_be_resolved_once() {
|
|||||||
assert!(requests[0].completed_at.is_some());
|
assert!(requests[0].completed_at.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn authenticated_delivery_failure_marks_accepted_receiver_failed() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let repository = Repository::open(temp.path()).await.unwrap();
|
||||||
|
repository
|
||||||
|
.insert_receiver_request(ReceiverRequestInsert {
|
||||||
|
id: "request-failed",
|
||||||
|
transfer_id: 78,
|
||||||
|
remote_endpoint_id: "node-a",
|
||||||
|
transfer_name: "demo",
|
||||||
|
receiver_name: Some("receiver"),
|
||||||
|
receiver_device_name: None,
|
||||||
|
app_version: "0.1.0",
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repository
|
||||||
|
.update_receiver_request_status("request-failed", ReceiverRequestStatus::Accepted, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repository
|
||||||
|
.set_receiver_receipt_token("request-failed", "token-hash")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
repository
|
||||||
|
.fail_receiver_delivery(
|
||||||
|
"request-failed",
|
||||||
|
78,
|
||||||
|
"node-a",
|
||||||
|
"token-hash",
|
||||||
|
"destination_exists",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repository
|
||||||
|
.fail_receiver_delivery(
|
||||||
|
"request-failed",
|
||||||
|
78,
|
||||||
|
"node-a",
|
||||||
|
"token-hash",
|
||||||
|
"destination_exists",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(repository
|
||||||
|
.fail_receiver_delivery(
|
||||||
|
"request-failed",
|
||||||
|
78,
|
||||||
|
"node-b",
|
||||||
|
"token-hash",
|
||||||
|
"destination_exists",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
|
||||||
|
let requests = repository.list_receiver_requests(78).await.unwrap();
|
||||||
|
assert_eq!(requests.len(), 1);
|
||||||
|
assert_eq!(requests[0].status, "failed");
|
||||||
|
assert_eq!(requests[0].reason.as_deref(), Some("destination_exists"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn startup_expiration_is_idempotent_for_pending_requests() {
|
async fn startup_expiration_is_idempotent_for_pending_requests() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
@@ -581,7 +645,7 @@ async fn migrates_schema_v2_identity_without_losing_transfer() {
|
|||||||
pool.close().await;
|
pool.close().await;
|
||||||
|
|
||||||
let repository = Repository::open(temp.path()).await.unwrap();
|
let repository = Repository::open(temp.path()).await.unwrap();
|
||||||
assert_eq!(repository.schema_version().await.unwrap(), 6);
|
assert_eq!(repository.schema_version().await.unwrap(), 7);
|
||||||
let stored = repository.list_transfers().await.unwrap().remove(0);
|
let stored = repository.list_transfers().await.unwrap().remove(0);
|
||||||
assert_eq!(stored.transfer_id, 7);
|
assert_eq!(stored.transfer_id, 7);
|
||||||
assert_eq!(stored.local_id, "legacy-7-send");
|
assert_eq!(stored.local_id, "legacy-7-send");
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
use std::sync::Arc;
|
use std::{sync::Arc, time::Duration};
|
||||||
|
|
||||||
use iroh_blobs::Hash;
|
use iroh_blobs::{
|
||||||
|
provider::{
|
||||||
|
events::{RequestUpdate, TransferCompleted},
|
||||||
|
TransferStats,
|
||||||
|
},
|
||||||
|
Hash,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
repository::{PendingDeliveryReceiptInsert, Repository, TransferUpsert},
|
repository::{PendingDeliveryReceiptInsert, Repository, TransferUpsert},
|
||||||
|
runtime::{consume_request_updates, RequestStreamOutcome},
|
||||||
transfer_state::{TransferDirection, TransferStatus},
|
transfer_state::{TransferDirection, TransferStatus},
|
||||||
CoreEvent, CoreEventSink, VnidropCore, VnidropError,
|
CoreEvent, CoreEventSink, VnidropCore, VnidropError,
|
||||||
};
|
};
|
||||||
@@ -14,6 +21,37 @@ impl CoreEventSink for TestSink {
|
|||||||
fn on_event(&self, _event: CoreEvent) {}
|
fn on_event(&self, _event: CoreEvent) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_request_stream_distinguishes_success_from_silent_abort() {
|
||||||
|
let runtime = tokio::runtime::Runtime::new().unwrap();
|
||||||
|
runtime.block_on(async {
|
||||||
|
let (completed_tx, completed_rx) = irpc::channel::mpsc::channel(1);
|
||||||
|
completed_tx
|
||||||
|
.send(RequestUpdate::Completed(TransferCompleted {
|
||||||
|
stats: Box::new(TransferStats {
|
||||||
|
payload_bytes_sent: 5,
|
||||||
|
other_bytes_sent: 0,
|
||||||
|
other_bytes_read: 0,
|
||||||
|
duration: Duration::ZERO,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
drop(completed_tx);
|
||||||
|
assert_eq!(
|
||||||
|
consume_request_updates(completed_rx, |_| {}).await,
|
||||||
|
RequestStreamOutcome::TerminalUpdateReceived
|
||||||
|
);
|
||||||
|
|
||||||
|
let (aborted_tx, aborted_rx) = irpc::channel::mpsc::channel::<RequestUpdate>(1);
|
||||||
|
drop(aborted_tx);
|
||||||
|
assert_eq!(
|
||||||
|
consume_request_updates(aborted_rx, |_| {}).await,
|
||||||
|
RequestStreamOutcome::Aborted
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn initializes_and_reports_endpoint() {
|
fn initializes_and_reports_endpoint() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
@@ -126,6 +164,7 @@ fn startup_processes_persisted_delivery_receipts() {
|
|||||||
request_id: "request-94",
|
request_id: "request-94",
|
||||||
sender_transfer_id: 49,
|
sender_transfer_id: 49,
|
||||||
token: "receipt-token",
|
token: "receipt-token",
|
||||||
|
failure_reason: None,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
|
use std::net::{Ipv4Addr, SocketAddr};
|
||||||
|
|
||||||
use data_encoding::BASE64URL_NOPAD;
|
use data_encoding::BASE64URL_NOPAD;
|
||||||
use iroh::SecretKey;
|
use iroh::{RelayUrl, SecretKey};
|
||||||
use iroh_blobs::{ticket::BlobTicket, BlobFormat, Hash};
|
use iroh_blobs::{ticket::BlobTicket, BlobFormat, Hash};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{CoreLimits, TransferMetadata},
|
api::{CoreLimits, CoreRelayMode, TransferMetadata},
|
||||||
ticket::{parse_transfer_ticket, parse_transfer_ticket_with_limits, VnidropTicket},
|
ticket::{
|
||||||
|
encode_persisted_sender_address, parse_persisted_sender_address, parse_transfer_ticket,
|
||||||
|
parse_transfer_ticket_with_limits, ticket_matches_relay_profile, VnidropTicket,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
fn blob_ticket(hash_byte: u8) -> BlobTicket {
|
fn blob_ticket(hash_byte: u8) -> BlobTicket {
|
||||||
@@ -25,7 +30,7 @@ fn metadata_ticket_round_trips() {
|
|||||||
3,
|
3,
|
||||||
2048,
|
2048,
|
||||||
);
|
);
|
||||||
let encoded = VnidropTicket::new(blob_ticket.clone(), metadata.clone())
|
let encoded = VnidropTicket::new_with_relay_urls(blob_ticket.clone(), metadata.clone(), &[])
|
||||||
.encode()
|
.encode()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let parsed = parse_transfer_ticket(&encoded).unwrap();
|
let parsed = parse_transfer_ticket(&encoded).unwrap();
|
||||||
@@ -38,7 +43,7 @@ fn metadata_ticket_round_trips() {
|
|||||||
fn metadata_ticket_tolerates_wrapped_whitespace() {
|
fn metadata_ticket_tolerates_wrapped_whitespace() {
|
||||||
let blob_ticket = blob_ticket(9);
|
let blob_ticket = blob_ticket(9);
|
||||||
let metadata = TransferMetadata::new(7, "Wrapped", None, blob_ticket.hash(), 1, 10);
|
let metadata = TransferMetadata::new(7, "Wrapped", None, blob_ticket.hash(), 1, 10);
|
||||||
let encoded = VnidropTicket::new(blob_ticket.clone(), metadata)
|
let encoded = VnidropTicket::new_with_relay_urls(blob_ticket.clone(), metadata, &[])
|
||||||
.encode()
|
.encode()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let wrapped = encoded
|
let wrapped = encoded
|
||||||
@@ -52,6 +57,147 @@ fn metadata_ticket_tolerates_wrapped_whitespace() {
|
|||||||
assert_eq!(parsed.blob_ticket.hash(), blob_ticket.hash());
|
assert_eq!(parsed.blob_ticket.hash(), blob_ticket.hash());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metadata_ticket_restores_backup_relays_without_losing_direct_addresses() {
|
||||||
|
let secret = SecretKey::generate();
|
||||||
|
let primary: RelayUrl = "https://a.relay.example.com".parse().unwrap();
|
||||||
|
let backup: RelayUrl = "https://b.relay.example.com".parse().unwrap();
|
||||||
|
let direct = SocketAddr::from((Ipv4Addr::LOCALHOST, 49152));
|
||||||
|
let addr = iroh::EndpointAddr::new(secret.public())
|
||||||
|
.with_relay_url(primary.clone())
|
||||||
|
.with_ip_addr(direct);
|
||||||
|
let blob_ticket = BlobTicket::new(addr, Hash::new([11; 32]), BlobFormat::HashSeq);
|
||||||
|
let metadata = TransferMetadata::new(11, "Backed up", None, blob_ticket.hash(), 1, 10);
|
||||||
|
|
||||||
|
let encoded = VnidropTicket::new_with_relay_urls(
|
||||||
|
blob_ticket,
|
||||||
|
metadata,
|
||||||
|
&[primary.clone(), backup.clone()],
|
||||||
|
)
|
||||||
|
.encode()
|
||||||
|
.unwrap();
|
||||||
|
let parsed = parse_transfer_ticket(&encoded).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
parsed
|
||||||
|
.blob_ticket
|
||||||
|
.addr()
|
||||||
|
.relay_urls()
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![primary, backup]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parsed
|
||||||
|
.blob_ticket
|
||||||
|
.addr()
|
||||||
|
.ip_addrs()
|
||||||
|
.copied()
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![direct]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn saved_ticket_relay_profile_matching_is_mode_aware_and_order_insensitive() {
|
||||||
|
let relay_a: RelayUrl = "https://a.relay.example.com".parse().unwrap();
|
||||||
|
let relay_b: RelayUrl = "https://b.relay.example.com".parse().unwrap();
|
||||||
|
let relay_c: RelayUrl = "https://c.relay.example.com".parse().unwrap();
|
||||||
|
let blob_ticket = blob_ticket(13);
|
||||||
|
let metadata = TransferMetadata::new(13, "Relay profile", None, blob_ticket.hash(), 1, 10);
|
||||||
|
let custom_ticket = VnidropTicket::new_with_relay_urls(
|
||||||
|
blob_ticket.clone(),
|
||||||
|
metadata.clone(),
|
||||||
|
&[relay_a.clone(), relay_b.clone()],
|
||||||
|
)
|
||||||
|
.encode()
|
||||||
|
.unwrap();
|
||||||
|
let automatic_ticket = VnidropTicket::new_with_relay_urls(blob_ticket, metadata, &[])
|
||||||
|
.encode()
|
||||||
|
.unwrap();
|
||||||
|
let limits = CoreLimits::default();
|
||||||
|
|
||||||
|
assert!(ticket_matches_relay_profile(
|
||||||
|
&custom_ticket,
|
||||||
|
&limits,
|
||||||
|
CoreRelayMode::StrictCustom,
|
||||||
|
&[relay_b.clone(), relay_a.clone()],
|
||||||
|
)
|
||||||
|
.unwrap());
|
||||||
|
assert!(ticket_matches_relay_profile(
|
||||||
|
&custom_ticket,
|
||||||
|
&limits,
|
||||||
|
CoreRelayMode::CustomWithDirectFallback,
|
||||||
|
&[relay_b.clone(), relay_a.clone()],
|
||||||
|
)
|
||||||
|
.unwrap());
|
||||||
|
assert!(!ticket_matches_relay_profile(
|
||||||
|
&custom_ticket,
|
||||||
|
&limits,
|
||||||
|
CoreRelayMode::StrictCustom,
|
||||||
|
&[relay_a.clone(), relay_c],
|
||||||
|
)
|
||||||
|
.unwrap());
|
||||||
|
assert!(
|
||||||
|
!ticket_matches_relay_profile(&custom_ticket, &limits, CoreRelayMode::Automatic, &[],)
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
assert!(ticket_matches_relay_profile(
|
||||||
|
&automatic_ticket,
|
||||||
|
&limits,
|
||||||
|
CoreRelayMode::Automatic,
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.unwrap());
|
||||||
|
assert!(!ticket_matches_relay_profile(
|
||||||
|
&automatic_ticket,
|
||||||
|
&limits,
|
||||||
|
CoreRelayMode::StrictCustom,
|
||||||
|
&[relay_a],
|
||||||
|
)
|
||||||
|
.unwrap());
|
||||||
|
assert!(ticket_matches_relay_profile(
|
||||||
|
&automatic_ticket,
|
||||||
|
&limits,
|
||||||
|
CoreRelayMode::LocalOnly,
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.unwrap());
|
||||||
|
assert!(
|
||||||
|
!ticket_matches_relay_profile(&custom_ticket, &limits, CoreRelayMode::LocalOnly, &[],)
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persisted_sender_address_preserves_relays_and_accepts_legacy_blob_ticket() {
|
||||||
|
let secret = SecretKey::generate();
|
||||||
|
let primary: RelayUrl = "https://a.relay.example.com".parse().unwrap();
|
||||||
|
let backup: RelayUrl = "https://b.relay.example.com".parse().unwrap();
|
||||||
|
let direct = SocketAddr::from((Ipv4Addr::LOCALHOST, 49153));
|
||||||
|
let addr = iroh::EndpointAddr::new(secret.public())
|
||||||
|
.with_relay_url(primary.clone())
|
||||||
|
.with_relay_url(backup)
|
||||||
|
.with_ip_addr(direct);
|
||||||
|
|
||||||
|
let encoded = encode_persisted_sender_address(&addr).unwrap();
|
||||||
|
assert_eq!(parse_persisted_sender_address(&encoded).unwrap(), addr);
|
||||||
|
|
||||||
|
let legacy_addr = iroh::EndpointAddr::new(secret.public())
|
||||||
|
.with_relay_url(primary)
|
||||||
|
.with_ip_addr(direct);
|
||||||
|
let legacy = BlobTicket::new(
|
||||||
|
legacy_addr.clone(),
|
||||||
|
Hash::new([12; 32]),
|
||||||
|
BlobFormat::HashSeq,
|
||||||
|
)
|
||||||
|
.to_string();
|
||||||
|
assert_eq!(
|
||||||
|
parse_persisted_sender_address(&legacy).unwrap(),
|
||||||
|
legacy_addr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invalid_ticket_is_rejected() {
|
fn invalid_ticket_is_rejected() {
|
||||||
assert!(parse_transfer_ticket("not-a-ticket").is_err());
|
assert!(parse_transfer_ticket("not-a-ticket").is_err());
|
||||||
|
|||||||
@@ -1,27 +1,38 @@
|
|||||||
use std::str::FromStr;
|
use std::{collections::BTreeSet, str::FromStr};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use data_encoding::BASE64URL_NOPAD;
|
use data_encoding::BASE64URL_NOPAD;
|
||||||
|
use iroh::{EndpointAddr, RelayUrl};
|
||||||
use iroh_blobs::ticket::BlobTicket;
|
use iroh_blobs::ticket::BlobTicket;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::api::{CoreLimits, TransferMetadata};
|
use crate::api::{CoreLimits, CoreNetworkConfig, CoreRelayMode, TransferMetadata};
|
||||||
|
|
||||||
const VNIDROP_TICKET_PREFIX: &str = "vnd1:";
|
const VNIDROP_TICKET_PREFIX: &str = "vnd1:";
|
||||||
const VNIDROP_TICKET_VERSION: u8 = 1;
|
const VNIDROP_TICKET_VERSION: u8 = 1;
|
||||||
|
const PERSISTED_SENDER_ADDRESS_PREFIX: &str = "vndaddr1:";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub(crate) struct VnidropTicket {
|
pub(crate) struct VnidropTicket {
|
||||||
version: u8,
|
version: u8,
|
||||||
blob_ticket: String,
|
blob_ticket: String,
|
||||||
|
// BlobTicket's current wire format retains only one relay URL. The outer
|
||||||
|
// envelope carries backups so new receivers can rebuild the full address.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
relay_urls: Vec<String>,
|
||||||
metadata: TransferMetadata,
|
metadata: TransferMetadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VnidropTicket {
|
impl VnidropTicket {
|
||||||
pub(crate) fn new(blob_ticket: BlobTicket, metadata: TransferMetadata) -> Self {
|
pub(crate) fn new_with_relay_urls(
|
||||||
|
blob_ticket: BlobTicket,
|
||||||
|
metadata: TransferMetadata,
|
||||||
|
relay_urls: &[RelayUrl],
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
version: VNIDROP_TICKET_VERSION,
|
version: VNIDROP_TICKET_VERSION,
|
||||||
blob_ticket: blob_ticket.to_string(),
|
blob_ticket: blob_ticket.to_string(),
|
||||||
|
relay_urls: relay_urls.iter().map(ToString::to_string).collect(),
|
||||||
metadata,
|
metadata,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,6 +61,35 @@ impl VnidropTicket {
|
|||||||
pub(crate) struct ParsedTransferTicket {
|
pub(crate) struct ParsedTransferTicket {
|
||||||
pub(crate) blob_ticket: BlobTicket,
|
pub(crate) blob_ticket: BlobTicket,
|
||||||
pub(crate) metadata: TransferMetadata,
|
pub(crate) metadata: TransferMetadata,
|
||||||
|
pub(crate) advertised_custom_relay_urls: Vec<RelayUrl>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct PersistedSenderAddress {
|
||||||
|
addr: EndpointAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn encode_persisted_sender_address(addr: &EndpointAddr) -> Result<String> {
|
||||||
|
let bytes = serde_json::to_vec(&PersistedSenderAddress { addr: addr.clone() })?;
|
||||||
|
Ok(format!(
|
||||||
|
"{PERSISTED_SENDER_ADDRESS_PREFIX}{}",
|
||||||
|
BASE64URL_NOPAD.encode(&bytes)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_persisted_sender_address(value: &str) -> Result<EndpointAddr> {
|
||||||
|
if let Some(encoded) = value.strip_prefix(PERSISTED_SENDER_ADDRESS_PREFIX) {
|
||||||
|
let bytes = BASE64URL_NOPAD
|
||||||
|
.decode(encoded.as_bytes())
|
||||||
|
.context("invalid persisted sender address encoding")?;
|
||||||
|
let persisted: PersistedSenderAddress =
|
||||||
|
serde_json::from_slice(&bytes).context("invalid persisted sender address payload")?;
|
||||||
|
return Ok(persisted.addr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rows created before multi-relay invitations stored a raw BlobTicket.
|
||||||
|
let legacy = BlobTicket::from_str(value).context("invalid legacy sender BlobTicket")?;
|
||||||
|
Ok(legacy.addr().clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -93,17 +133,57 @@ pub(crate) fn parse_transfer_ticket_with_limits(
|
|||||||
Some(ticket.metadata.transfer_name.as_str()),
|
Some(ticket.metadata.transfer_name.as_str()),
|
||||||
)?;
|
)?;
|
||||||
limits.validate_metadata_text("sender name", ticket.metadata.sender_name.as_deref())?;
|
limits.validate_metadata_text("sender name", ticket.metadata.sender_name.as_deref())?;
|
||||||
let blob_ticket = BlobTicket::from_str(&ticket.blob_ticket)
|
let mut blob_ticket = BlobTicket::from_str(&ticket.blob_ticket)
|
||||||
.context("invalid BlobTicket inside VniDrop ticket")?;
|
.context("invalid BlobTicket inside VniDrop ticket")?;
|
||||||
if ticket.metadata.content_hash != blob_ticket.hash().to_string() {
|
if ticket.metadata.content_hash != blob_ticket.hash().to_string() {
|
||||||
anyhow::bail!("VniDrop ticket metadata hash does not match BlobTicket hash");
|
anyhow::bail!("VniDrop ticket metadata hash does not match BlobTicket hash");
|
||||||
}
|
}
|
||||||
|
let advertised_custom_relay_urls = if ticket.relay_urls.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
CoreNetworkConfig {
|
||||||
|
mode: CoreRelayMode::StrictCustom,
|
||||||
|
relay_urls: ticket.relay_urls,
|
||||||
|
}
|
||||||
|
.validated_relay_urls()
|
||||||
|
.context("invalid relay URLs inside VniDrop ticket")?
|
||||||
|
};
|
||||||
|
if !advertised_custom_relay_urls.is_empty() {
|
||||||
|
let (mut addr, hash, format) = blob_ticket.into_parts();
|
||||||
|
for relay_url in advertised_custom_relay_urls.iter().cloned() {
|
||||||
|
addr = addr.with_relay_url(relay_url);
|
||||||
|
}
|
||||||
|
blob_ticket = BlobTicket::new(addr, hash, format);
|
||||||
|
}
|
||||||
Ok(ParsedTransferTicket {
|
Ok(ParsedTransferTicket {
|
||||||
blob_ticket,
|
blob_ticket,
|
||||||
metadata: ticket.metadata,
|
metadata: ticket.metadata,
|
||||||
|
advertised_custom_relay_urls,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn ticket_matches_relay_profile(
|
||||||
|
value: &str,
|
||||||
|
limits: &CoreLimits,
|
||||||
|
relay_mode: CoreRelayMode,
|
||||||
|
custom_relay_urls: &[RelayUrl],
|
||||||
|
) -> Result<bool> {
|
||||||
|
let parsed = parse_transfer_ticket_with_limits(value, limits)?;
|
||||||
|
match relay_mode {
|
||||||
|
CoreRelayMode::Automatic => Ok(parsed.advertised_custom_relay_urls.is_empty()),
|
||||||
|
CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback => {
|
||||||
|
let advertised = parsed
|
||||||
|
.advertised_custom_relay_urls
|
||||||
|
.into_iter()
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
let configured = custom_relay_urls.iter().cloned().collect::<BTreeSet<_>>();
|
||||||
|
Ok(advertised == configured)
|
||||||
|
}
|
||||||
|
CoreRelayMode::LocalOnly => Ok(parsed.advertised_custom_relay_urls.is_empty()
|
||||||
|
&& parsed.blob_ticket.addr().relay_urls().next().is_none()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_ticket_input(value: &str) -> String {
|
fn normalize_ticket_input(value: &str) -> String {
|
||||||
// Tickets are commonly copied from text views or chat apps that insert line
|
// Tickets are commonly copied from text views or chat apps that insert line
|
||||||
// breaks. Strip whitespace only; other corrupt characters should still be
|
// breaks. Strip whitespace only; other corrupt characters should still be
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ pub(crate) enum ReceiverRequestStatus {
|
|||||||
Refused,
|
Refused,
|
||||||
Expired,
|
Expired,
|
||||||
Completed,
|
Completed,
|
||||||
|
Failed,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ReceiverRequestStatus {
|
impl ReceiverRequestStatus {
|
||||||
@@ -97,6 +98,7 @@ impl ReceiverRequestStatus {
|
|||||||
Self::Refused => "refused",
|
Self::Refused => "refused",
|
||||||
Self::Expired => "expired",
|
Self::Expired => "expired",
|
||||||
Self::Completed => "completed",
|
Self::Completed => "completed",
|
||||||
|
Self::Failed => "failed",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,6 +113,7 @@ impl TryFrom<&str> for ReceiverRequestStatus {
|
|||||||
"refused" => Ok(Self::Refused),
|
"refused" => Ok(Self::Refused),
|
||||||
"expired" => Ok(Self::Expired),
|
"expired" => Ok(Self::Expired),
|
||||||
"completed" => Ok(Self::Completed),
|
"completed" => Ok(Self::Completed),
|
||||||
|
"failed" => Ok(Self::Failed),
|
||||||
_ => bail!("unknown receiver request status: {value}"),
|
_ => bail!("unknown receiver request status: {value}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,14 +73,23 @@ fn public_share_receives_without_sender_approval() {
|
|||||||
assert_eq!(deliveries[0].receiver_name.as_deref(), Some("Receiver"));
|
assert_eq!(deliveries[0].receiver_name.as_deref(), Some("Receiver"));
|
||||||
assert_eq!(deliveries[0].status, "completed");
|
assert_eq!(deliveries[0].status, "completed");
|
||||||
assert!(deliveries[0].completed_at.is_some());
|
assert!(deliveries[0].completed_at.is_some());
|
||||||
assert!(
|
// The repository commit becomes visible just before the receipt handler
|
||||||
sender.sink.events().iter().any(|event| {
|
// emits its event, so completion and sink observation are not atomic.
|
||||||
|
let started = Instant::now();
|
||||||
|
loop {
|
||||||
|
if sender.sink.events().iter().any(|event| {
|
||||||
event.phase == "delivery"
|
event.phase == "delivery"
|
||||||
&& event.kind == "receiver-completed"
|
&& event.kind == "receiver-completed"
|
||||||
&& event.transfer_id == Some(share.transfer_id)
|
&& event.transfer_id == Some(share.transfer_id)
|
||||||
}),
|
}) {
|
||||||
"delivery receipts must emit a delivery phase event for UI live updates"
|
break;
|
||||||
);
|
}
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < Duration::from_secs(5),
|
||||||
|
"delivery receipts must emit a delivery phase event for UI live updates"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
146
crates/vnidrop/tests/custom_relay.rs
Normal file
146
crates/vnidrop/tests/custom_relay.rs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
mod support;
|
||||||
|
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use data_encoding::BASE64URL_NOPAD;
|
||||||
|
use iroh::{EndpointAddr, RelayUrl};
|
||||||
|
use iroh_blobs::ticket::BlobTicket;
|
||||||
|
use serde_json::Value;
|
||||||
|
use support::{receive_with_response, share_path, TestNode, TestRelay};
|
||||||
|
use vnidrop::{CoreNetworkConfig, CoreRelayMode};
|
||||||
|
|
||||||
|
fn custom_config(relay_urls: &[&str]) -> CoreNetworkConfig {
|
||||||
|
CoreNetworkConfig {
|
||||||
|
mode: CoreRelayMode::StrictCustom,
|
||||||
|
relay_urls: relay_urls.iter().map(ToString::to_string).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_only_config() -> CoreNetworkConfig {
|
||||||
|
CoreNetworkConfig {
|
||||||
|
mode: CoreRelayMode::LocalOnly,
|
||||||
|
relay_urls: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_blob_ticket(ticket: &str) -> (Value, BlobTicket) {
|
||||||
|
let encoded = ticket.strip_prefix("vnd1:").unwrap();
|
||||||
|
let payload = BASE64URL_NOPAD.decode(encoded.as_bytes()).unwrap();
|
||||||
|
let value: Value = serde_json::from_slice(&payload).unwrap();
|
||||||
|
let blob_ticket = BlobTicket::from_str(value["blob_ticket"].as_str().unwrap()).unwrap();
|
||||||
|
let (mut addr, hash, format) = blob_ticket.into_parts();
|
||||||
|
if let Some(relay_urls) = value["relay_urls"].as_array() {
|
||||||
|
for relay_url in relay_urls {
|
||||||
|
addr = addr.with_relay_url(relay_url.as_str().unwrap().parse().unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let blob_ticket = BlobTicket::new(addr, hash, format);
|
||||||
|
(value, blob_ticket)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_only_mode_advertises_direct_addresses_and_transfers_on_lan() {
|
||||||
|
let sender = TestNode::with_network_config(local_only_config());
|
||||||
|
let receiver = TestNode::with_network_config(local_only_config());
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let output_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_path = source_dir.path().join("local-only.txt");
|
||||||
|
std::fs::write(&source_path, b"direct on the local network").unwrap();
|
||||||
|
|
||||||
|
let share = share_path(&sender.core, &source_path, 402, "local-only.txt", false);
|
||||||
|
let (ticket_value, blob_ticket) = read_blob_ticket(&share.ticket);
|
||||||
|
assert!(ticket_value.get("relay_urls").is_none());
|
||||||
|
assert_eq!(blob_ticket.addr().relay_urls().count(), 0);
|
||||||
|
assert!(!sender.core.status().addr.contains("iroh.link"));
|
||||||
|
|
||||||
|
receive_with_response(
|
||||||
|
&sender.core,
|
||||||
|
share.transfer_id,
|
||||||
|
receiver.core.arc(),
|
||||||
|
share.ticket,
|
||||||
|
output_dir.path(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(output_dir.path().join("local-only.txt")).unwrap(),
|
||||||
|
b"direct on the local network"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_relay_only_address(ticket: &str, relay_url: &str) -> String {
|
||||||
|
let (mut value, blob_ticket) = read_blob_ticket(ticket);
|
||||||
|
let relay_url: RelayUrl = relay_url.parse().unwrap();
|
||||||
|
let relay_only_addr = EndpointAddr::new(blob_ticket.addr().id).with_relay_url(relay_url);
|
||||||
|
let relay_only_ticket =
|
||||||
|
BlobTicket::new(relay_only_addr, blob_ticket.hash(), blob_ticket.format());
|
||||||
|
value["blob_ticket"] = Value::String(relay_only_ticket.to_string());
|
||||||
|
let payload = serde_json::to_vec(&value).unwrap();
|
||||||
|
format!("vnd1:{}", BASE64URL_NOPAD.encode(&payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strict_custom_relay_is_advertised_and_transfers_without_direct_ticket_addresses() {
|
||||||
|
let relay = TestRelay::start();
|
||||||
|
let backup_relay = "http://127.0.0.1:9";
|
||||||
|
let relay_urls = [relay.url.as_str(), backup_relay];
|
||||||
|
let sender = TestNode::with_network_config(custom_config(&relay_urls));
|
||||||
|
let receiver = TestNode::with_network_config(custom_config(&[relay.url.as_str()]));
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let output_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_path = source_dir.path().join("custom-relay.txt");
|
||||||
|
std::fs::write(&source_path, b"through the custom relay").unwrap();
|
||||||
|
|
||||||
|
let share = share_path(&sender.core, &source_path, 401, "custom-relay.txt", false);
|
||||||
|
let (ticket_value, blob_ticket) = read_blob_ticket(&share.ticket);
|
||||||
|
let advertised_relays: Vec<_> = blob_ticket
|
||||||
|
.addr()
|
||||||
|
.relay_urls()
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect();
|
||||||
|
let configured_relay = RelayUrl::from_str(&relay.url).unwrap().to_string();
|
||||||
|
let configured_backup = RelayUrl::from_str(backup_relay).unwrap().to_string();
|
||||||
|
let envelope_relays = ticket_value["relay_urls"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|value| value.as_str().unwrap().to_string())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
envelope_relays,
|
||||||
|
vec![configured_relay.clone(), configured_backup.clone()]
|
||||||
|
);
|
||||||
|
let mut configured_relays = vec![configured_relay.clone(), configured_backup];
|
||||||
|
configured_relays.sort();
|
||||||
|
assert_eq!(advertised_relays, configured_relays.clone());
|
||||||
|
assert!(!advertised_relays.iter().any(|url| url.contains("n0")));
|
||||||
|
assert!(!sender.core.status().addr.contains("iroh.link"));
|
||||||
|
|
||||||
|
let relay_only_ticket = with_relay_only_address(&share.ticket, &relay.url);
|
||||||
|
let (_, relay_only_blob_ticket) = read_blob_ticket(&relay_only_ticket);
|
||||||
|
assert_eq!(relay_only_blob_ticket.addr().ip_addrs().count(), 0);
|
||||||
|
assert_eq!(
|
||||||
|
relay_only_blob_ticket
|
||||||
|
.addr()
|
||||||
|
.relay_urls()
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
configured_relays
|
||||||
|
);
|
||||||
|
|
||||||
|
receive_with_response(
|
||||||
|
&sender.core,
|
||||||
|
share.transfer_id,
|
||||||
|
receiver.core.arc(),
|
||||||
|
relay_only_ticket,
|
||||||
|
output_dir.path(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(output_dir.path().join("custom-relay.txt")).unwrap(),
|
||||||
|
b"through the custom relay"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,10 +5,10 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use futures_lite::StreamExt as _;
|
use futures_lite::StreamExt as _;
|
||||||
use iroh_blobs::store::fs::FsStore;
|
use iroh_blobs::store::fs::FsStore;
|
||||||
use support::{share_path, CoreGuard, RecordingSink, TestNode};
|
use support::{share_path, CoreGuard, RecordingSink, TestNode, TestRelay};
|
||||||
use vnidrop::{
|
use vnidrop::{
|
||||||
CoreEvent, CoreEventSink, CoreLimits, ShareMetadataInput, ShareSource, SourceKind,
|
CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, ShareMetadataInput,
|
||||||
TransferAccessMode,
|
ShareSource, SourceKind, TransferAccessMode,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -137,6 +137,56 @@ fn persisted_share_is_recovered_and_can_be_stopped_after_restart() {
|
|||||||
assert_eq!(restarted.status().active_shares, 0);
|
assert_eq!(restarted.status().active_shares, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persisted_share_is_revoked_when_restarted_with_a_different_relay_profile() {
|
||||||
|
let relay_a = TestRelay::start();
|
||||||
|
let relay_b = TestRelay::start();
|
||||||
|
assert_ne!(relay_a.url, relay_b.url);
|
||||||
|
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let core_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_path = source_dir.path().join("stale-relay.txt");
|
||||||
|
std::fs::write(&source_path, b"must not survive a relay profile change").unwrap();
|
||||||
|
let sender = CoreGuard::start_with_network_config(
|
||||||
|
core_dir.path(),
|
||||||
|
Arc::new(RecordingSink::default()),
|
||||||
|
CoreNetworkConfig {
|
||||||
|
mode: CoreRelayMode::StrictCustom,
|
||||||
|
relay_urls: vec![relay_a.url.clone()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let share = share_path(&sender, &source_path, 120, "stale-relay.txt", false);
|
||||||
|
drop(sender);
|
||||||
|
drop(relay_a);
|
||||||
|
|
||||||
|
let restarted = CoreGuard::start_with_network_config(
|
||||||
|
core_dir.path(),
|
||||||
|
Arc::new(RecordingSink::default()),
|
||||||
|
CoreNetworkConfig {
|
||||||
|
mode: CoreRelayMode::StrictCustom,
|
||||||
|
relay_urls: vec![relay_b.url.clone()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(restarted.status().active_shares, 0);
|
||||||
|
let transfer = restarted
|
||||||
|
.list_transfers()
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.find(|transfer| transfer.transfer_id == share.transfer_id)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(transfer.status, "stopped");
|
||||||
|
assert!(restarted
|
||||||
|
.list_events(Some(share.transfer_id))
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|event| {
|
||||||
|
event.phase == "recovery" && event.kind == "share-stopped-network-profile-changed"
|
||||||
|
}));
|
||||||
|
drop(restarted);
|
||||||
|
assert_eq!(share_tag_count(core_dir.path()), 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stopped_share_rejects_receive() {
|
fn stopped_share_rejects_receive() {
|
||||||
let source_dir = tempfile::tempdir().unwrap();
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
26
crates/vnidrop/tests/storage.rs
Normal file
26
crates/vnidrop/tests/storage.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
use vnidrop::clear_inactive_transfer_cache;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inactive_transfer_cache_is_removed_and_reports_reclaimed_bytes() {
|
||||||
|
let app_data = tempfile::tempdir().unwrap();
|
||||||
|
let blobs = app_data.path().join("blobs");
|
||||||
|
std::fs::create_dir_all(blobs.join("data")).unwrap();
|
||||||
|
std::fs::write(blobs.join("data").join("payload"), vec![9u8; 4096]).unwrap();
|
||||||
|
std::fs::write(blobs.join("blobs.db"), vec![3u8; 512]).unwrap();
|
||||||
|
|
||||||
|
let reclaimed =
|
||||||
|
clear_inactive_transfer_cache(app_data.path().to_string_lossy().into_owned()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(reclaimed, 4608);
|
||||||
|
assert!(!blobs.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inactive_transfer_cache_rejects_relative_app_data_paths() {
|
||||||
|
assert!(clear_inactive_transfer_cache("relative/path".to_string()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inactive_transfer_cache_rejects_filesystem_roots() {
|
||||||
|
assert!(clear_inactive_transfer_cache(std::path::MAIN_SEPARATOR_STR.to_string()).is_err());
|
||||||
|
}
|
||||||
@@ -8,15 +8,16 @@ use std::{
|
|||||||
path::Path,
|
path::Path,
|
||||||
sync::{
|
sync::{
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
Arc, Condvar, Mutex,
|
mpsc, Arc, Condvar, Mutex,
|
||||||
},
|
},
|
||||||
|
thread::JoinHandle,
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
use vnidrop::{
|
use vnidrop::{
|
||||||
CoreEvent, CoreEventSink, CoreLimits, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2,
|
CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, PublishedOutput, ReceiveOutputSink,
|
||||||
ReceivedLocatorKind, ReceiverRequest, ShareMetadataInput, ShareResult, ShareSource, SourceKind,
|
ReceiveOutputSinkV2, ReceivedLocatorKind, ReceiverRequest, ShareMetadataInput, ShareResult,
|
||||||
TransferAccessMode, VnidropCore, VnidropError,
|
ShareSource, SourceKind, TransferAccessMode, VnidropCore, VnidropError,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -57,6 +58,21 @@ impl CoreGuard {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn start_with_network_config(
|
||||||
|
path: &Path,
|
||||||
|
sink: Arc<dyn CoreEventSink>,
|
||||||
|
network_config: CoreNetworkConfig,
|
||||||
|
) -> Self {
|
||||||
|
Self(
|
||||||
|
VnidropCore::initialize_with_network_config(
|
||||||
|
path.to_string_lossy().to_string(),
|
||||||
|
sink,
|
||||||
|
network_config,
|
||||||
|
)
|
||||||
|
.expect("test core should initialize with network config"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn arc(&self) -> Arc<VnidropCore> {
|
pub fn arc(&self) -> Arc<VnidropCore> {
|
||||||
self.0.clone()
|
self.0.clone()
|
||||||
}
|
}
|
||||||
@@ -93,6 +109,80 @@ impl TestNode {
|
|||||||
sink,
|
sink,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_network_config(network_config: CoreNetworkConfig) -> Self {
|
||||||
|
let data_dir = tempfile::tempdir().unwrap();
|
||||||
|
let sink = Arc::new(RecordingSink::default());
|
||||||
|
let core =
|
||||||
|
CoreGuard::start_with_network_config(data_dir.path(), sink.clone(), network_config);
|
||||||
|
Self {
|
||||||
|
_data_dir: data_dir,
|
||||||
|
core,
|
||||||
|
sink,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TestRelay {
|
||||||
|
pub url: String,
|
||||||
|
shutdown: Option<tokio::sync::oneshot::Sender<()>>,
|
||||||
|
thread: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestRelay {
|
||||||
|
pub fn start() -> Self {
|
||||||
|
let (ready_tx, ready_rx) = mpsc::sync_channel(1);
|
||||||
|
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
|
||||||
|
let thread = std::thread::spawn(move || {
|
||||||
|
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.thread_name("vnidrop-test-relay")
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
runtime.block_on(async move {
|
||||||
|
let relay =
|
||||||
|
iroh_relay::server::RelayConfig::new((std::net::Ipv4Addr::LOCALHOST, 0));
|
||||||
|
let mut config = iroh_relay::server::ServerConfig::default();
|
||||||
|
config.relay = Some(relay);
|
||||||
|
let server = match iroh_relay::server::Server::spawn(config).await {
|
||||||
|
Ok(server) => server,
|
||||||
|
Err(error) => {
|
||||||
|
ready_tx.send(Err(error.to_string())).ok();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let url = format!(
|
||||||
|
"http://{}",
|
||||||
|
server.http_addr().expect("HTTP relay should be bound")
|
||||||
|
);
|
||||||
|
if ready_tx.send(Ok(url)).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
shutdown_rx.await.ok();
|
||||||
|
drop(server);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let url = ready_rx
|
||||||
|
.recv_timeout(Duration::from_secs(5))
|
||||||
|
.expect("test relay should start")
|
||||||
|
.expect("test relay should bind");
|
||||||
|
Self {
|
||||||
|
url,
|
||||||
|
shutdown: Some(shutdown_tx),
|
||||||
|
thread: Some(thread),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TestRelay {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(shutdown) = self.shutdown.take() {
|
||||||
|
shutdown.send(()).ok();
|
||||||
|
}
|
||||||
|
if let Some(thread) = self.thread.take() {
|
||||||
|
thread.join().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
|
|||||||
@@ -1,7 +1,52 @@
|
|||||||
mod support;
|
mod support;
|
||||||
|
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use support::{receive_with_response, share_path, TestNode};
|
use support::{receive_with_response, share_path, TestNode};
|
||||||
use vnidrop::VnidropError;
|
use vnidrop::{CoreEvent, VnidropError};
|
||||||
|
|
||||||
|
fn wait_for_sender_transfer_event(sender: &TestNode, transfer_id: u64, kind: &str) -> CoreEvent {
|
||||||
|
let started = Instant::now();
|
||||||
|
loop {
|
||||||
|
if let Some(event) = sender.sink.events().into_iter().find(|event| {
|
||||||
|
event.transfer_id == Some(transfer_id)
|
||||||
|
&& event.direction.as_deref() == Some("send")
|
||||||
|
&& event.phase == "transfer"
|
||||||
|
&& event.kind == kind
|
||||||
|
}) {
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < Duration::from_secs(5),
|
||||||
|
"timed out waiting for sender transfer event {kind}"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_for_receiver_status(
|
||||||
|
sender: &TestNode,
|
||||||
|
transfer_id: u64,
|
||||||
|
status: &str,
|
||||||
|
) -> vnidrop::ReceiverRequest {
|
||||||
|
let started = Instant::now();
|
||||||
|
loop {
|
||||||
|
if let Some(request) = sender
|
||||||
|
.core
|
||||||
|
.list_receiver_requests(transfer_id)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.find(|request| request.status == status)
|
||||||
|
{
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < Duration::from_secs(5),
|
||||||
|
"timed out waiting for receiver status {status}"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn transfers_file_between_two_cores() {
|
fn transfers_file_between_two_cores() {
|
||||||
@@ -50,6 +95,12 @@ fn transfers_file_between_two_cores() {
|
|||||||
artifacts[0].locator,
|
artifacts[0].locator,
|
||||||
output_dir.path().join("hello.txt").to_string_lossy()
|
output_dir.path().join("hello.txt").to_string_lossy()
|
||||||
);
|
);
|
||||||
|
let completed = wait_for_sender_transfer_event(&sender, share.transfer_id, "completed");
|
||||||
|
assert!(completed.data_json.contains("\"connection_id\":"));
|
||||||
|
assert!(completed.data_json.contains("\"request_id\":"));
|
||||||
|
assert!(completed
|
||||||
|
.data_json
|
||||||
|
.contains(receiver.core.status().endpoint_id.as_str()));
|
||||||
|
|
||||||
receiver.core.delete_receive_history().unwrap();
|
receiver.core.delete_receive_history().unwrap();
|
||||||
assert_eq!(receiver.core.list_received_artifacts().unwrap(), artifacts);
|
assert_eq!(receiver.core.list_received_artifacts().unwrap(), artifacts);
|
||||||
@@ -135,4 +186,11 @@ fn receive_refuses_to_overwrite_existing_destination() {
|
|||||||
&& event.kind == "failed"
|
&& event.kind == "failed"
|
||||||
&& event.data_json.contains("\"code\":\"destination_exists\"")
|
&& event.data_json.contains("\"code\":\"destination_exists\"")
|
||||||
}));
|
}));
|
||||||
|
let failed = wait_for_receiver_status(&sender, share.transfer_id, "failed");
|
||||||
|
assert_eq!(failed.reason.as_deref(), Some("destination_exists"));
|
||||||
|
assert!(sender.sink.events().iter().any(|event| {
|
||||||
|
event.transfer_id == Some(share.transfer_id)
|
||||||
|
&& event.phase == "delivery"
|
||||||
|
&& event.kind == "receiver-failed"
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,6 +321,20 @@
|
|||||||
"ru": "О приложении"
|
"ru": "О приложении"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"app_starting": {
|
||||||
|
"context": "Shown briefly at launch while the core is still starting up.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Starting…",
|
||||||
|
"fr": "Démarrage…",
|
||||||
|
"es": "Iniciando…",
|
||||||
|
"it": "Avvio…",
|
||||||
|
"de": "Wird gestartet…",
|
||||||
|
"pt": "A iniciar…",
|
||||||
|
"pl": "Uruchamianie…",
|
||||||
|
"nl": "Bezig met starten…",
|
||||||
|
"ru": "Запуск…"
|
||||||
|
}
|
||||||
|
},
|
||||||
"appearance_auto_description": {
|
"appearance_auto_description": {
|
||||||
"context": "Settings > Appearance: description for the System/auto option.",
|
"context": "Settings > Appearance: description for the System/auto option.",
|
||||||
"translations": {
|
"translations": {
|
||||||
@@ -814,6 +828,20 @@
|
|||||||
"ru": "Назад"
|
"ru": "Назад"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"button_more_actions": {
|
||||||
|
"context": "Accessibility label for a button that opens more actions for an item.",
|
||||||
|
"translations": {
|
||||||
|
"en": "More actions",
|
||||||
|
"fr": "Plus d’actions",
|
||||||
|
"es": "Más acciones",
|
||||||
|
"it": "Altre azioni",
|
||||||
|
"de": "Weitere Aktionen",
|
||||||
|
"pt": "Mais ações",
|
||||||
|
"pl": "Więcej działań",
|
||||||
|
"nl": "Meer acties",
|
||||||
|
"ru": "Другие действия"
|
||||||
|
}
|
||||||
|
},
|
||||||
"button_cancel": {
|
"button_cancel": {
|
||||||
"context": "Button: cancel the current action or dialog.",
|
"context": "Button: cancel the current action or dialog.",
|
||||||
"translations": {
|
"translations": {
|
||||||
@@ -1812,9 +1840,6 @@
|
|||||||
},
|
},
|
||||||
"notifications_receive_completed_body": {
|
"notifications_receive_completed_body": {
|
||||||
"context": "Notification body shown when an incoming transfer finishes downloading. {transferName} = transfer name.",
|
"context": "Notification body shown when an incoming transfer finishes downloading. {transferName} = transfer name.",
|
||||||
"targets": [
|
|
||||||
"apple"
|
|
||||||
],
|
|
||||||
"args": [
|
"args": [
|
||||||
{
|
{
|
||||||
"name": "transferName",
|
"name": "transferName",
|
||||||
@@ -1835,9 +1860,6 @@
|
|||||||
},
|
},
|
||||||
"notifications_receive_completed_title": {
|
"notifications_receive_completed_title": {
|
||||||
"context": "Notification title shown when an incoming transfer finishes downloading.",
|
"context": "Notification title shown when an incoming transfer finishes downloading.",
|
||||||
"targets": [
|
|
||||||
"apple"
|
|
||||||
],
|
|
||||||
"translations": {
|
"translations": {
|
||||||
"en": "Download complete",
|
"en": "Download complete",
|
||||||
"fr": "Téléchargement terminé",
|
"fr": "Téléchargement terminé",
|
||||||
@@ -1852,9 +1874,6 @@
|
|||||||
},
|
},
|
||||||
"notifications_receive_failed_body": {
|
"notifications_receive_failed_body": {
|
||||||
"context": "Notification body shown when an incoming transfer fails. {transferName} = transfer name.",
|
"context": "Notification body shown when an incoming transfer fails. {transferName} = transfer name.",
|
||||||
"targets": [
|
|
||||||
"apple"
|
|
||||||
],
|
|
||||||
"args": [
|
"args": [
|
||||||
{
|
{
|
||||||
"name": "transferName",
|
"name": "transferName",
|
||||||
@@ -1875,9 +1894,6 @@
|
|||||||
},
|
},
|
||||||
"notifications_receive_failed_title": {
|
"notifications_receive_failed_title": {
|
||||||
"context": "Notification title shown when an incoming transfer fails.",
|
"context": "Notification title shown when an incoming transfer fails.",
|
||||||
"targets": [
|
|
||||||
"apple"
|
|
||||||
],
|
|
||||||
"translations": {
|
"translations": {
|
||||||
"en": "Download failed",
|
"en": "Download failed",
|
||||||
"fr": "Échec du téléchargement",
|
"fr": "Échec du téléchargement",
|
||||||
@@ -1892,9 +1908,6 @@
|
|||||||
},
|
},
|
||||||
"notifications_receiver_completed_body": {
|
"notifications_receiver_completed_body": {
|
||||||
"context": "Notification body shown to the sender when a receiver finishes downloading a shared transfer. {receiver} = receiver name, {transferName} = transfer name.",
|
"context": "Notification body shown to the sender when a receiver finishes downloading a shared transfer. {receiver} = receiver name, {transferName} = transfer name.",
|
||||||
"targets": [
|
|
||||||
"apple"
|
|
||||||
],
|
|
||||||
"args": [
|
"args": [
|
||||||
{
|
{
|
||||||
"name": "receiver",
|
"name": "receiver",
|
||||||
@@ -1919,9 +1932,6 @@
|
|||||||
},
|
},
|
||||||
"notifications_receiver_completed_title": {
|
"notifications_receiver_completed_title": {
|
||||||
"context": "Notification title shown to the sender when a receiver finishes downloading a shared transfer.",
|
"context": "Notification title shown to the sender when a receiver finishes downloading a shared transfer.",
|
||||||
"targets": [
|
|
||||||
"apple"
|
|
||||||
],
|
|
||||||
"translations": {
|
"translations": {
|
||||||
"en": "Transfer received",
|
"en": "Transfer received",
|
||||||
"fr": "Transfert reçu",
|
"fr": "Transfert reçu",
|
||||||
@@ -1934,11 +1944,46 @@
|
|||||||
"ru": "Передача получена"
|
"ru": "Передача получена"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"notifications_receiver_failed_body": {
|
||||||
|
"context": "Notification body shown to the sender when a receiver's download fails. {receiver} = receiver name, {transferName} = transfer name.",
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "receiver",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "transferName",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"translations": {
|
||||||
|
"en": "{receiver} couldn't receive “{transferName}”",
|
||||||
|
"fr": "{receiver} n'a pas pu recevoir « {transferName} »",
|
||||||
|
"es": "{receiver} no pudo recibir «{transferName}»",
|
||||||
|
"it": "{receiver} non ha potuto ricevere “{transferName}”",
|
||||||
|
"de": "{receiver} konnte „{transferName}“ nicht empfangen",
|
||||||
|
"pt": "{receiver} não conseguiu receber “{transferName}”",
|
||||||
|
"pl": "{receiver} nie mógł odebrać „{transferName}”",
|
||||||
|
"nl": "{receiver} kon “{transferName}” niet ontvangen",
|
||||||
|
"ru": "{receiver} не удалось получить «{transferName}»"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifications_receiver_failed_title": {
|
||||||
|
"context": "Notification title shown to the sender when a receiver's download fails.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Delivery failed",
|
||||||
|
"fr": "Échec de l'envoi",
|
||||||
|
"es": "Error en la entrega",
|
||||||
|
"it": "Consegna non riuscita",
|
||||||
|
"de": "Übertragung fehlgeschlagen",
|
||||||
|
"pt": "Falha na entrega",
|
||||||
|
"pl": "Dostarczenie nie powiodło się",
|
||||||
|
"nl": "Levering mislukt",
|
||||||
|
"ru": "Ошибка доставки"
|
||||||
|
}
|
||||||
|
},
|
||||||
"notifications_send_failed_body": {
|
"notifications_send_failed_body": {
|
||||||
"context": "Notification body shown to the sender when a shared transfer fails. {transferName} = transfer name.",
|
"context": "Notification body shown to the sender when a shared transfer fails. {transferName} = transfer name.",
|
||||||
"targets": [
|
|
||||||
"apple"
|
|
||||||
],
|
|
||||||
"args": [
|
"args": [
|
||||||
{
|
{
|
||||||
"name": "transferName",
|
"name": "transferName",
|
||||||
@@ -1959,9 +2004,6 @@
|
|||||||
},
|
},
|
||||||
"notifications_send_failed_title": {
|
"notifications_send_failed_title": {
|
||||||
"context": "Notification title shown to the sender when a shared transfer fails.",
|
"context": "Notification title shown to the sender when a shared transfer fails.",
|
||||||
"targets": [
|
|
||||||
"apple"
|
|
||||||
],
|
|
||||||
"translations": {
|
"translations": {
|
||||||
"en": "Sharing failed",
|
"en": "Sharing failed",
|
||||||
"fr": "Échec du partage",
|
"fr": "Échec du partage",
|
||||||
@@ -2658,6 +2700,394 @@
|
|||||||
"ru": "Передача VniDrop"
|
"ru": "Передача VniDrop"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"relay_add_url": {
|
||||||
|
"context": "Apple Network settings button that appends another custom relay URL field.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Add relay server",
|
||||||
|
"fr": "Ajouter un serveur relais",
|
||||||
|
"es": "Añadir servidor de retransmisión",
|
||||||
|
"it": "Aggiungi server relay",
|
||||||
|
"de": "Relay-Server hinzufügen",
|
||||||
|
"pt": "Adicionar servidor de retransmissão",
|
||||||
|
"pl": "Dodaj serwer przekaźnikowy",
|
||||||
|
"nl": "Relayserver toevoegen",
|
||||||
|
"ru": "Добавить сервер-ретранслятор"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relay_apply": {
|
||||||
|
"context": "Network settings button that activates the selected relay configuration.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Apply network settings",
|
||||||
|
"fr": "Appliquer les réglages réseau",
|
||||||
|
"es": "Aplicar ajustes de red",
|
||||||
|
"it": "Applica impostazioni di rete",
|
||||||
|
"de": "Netzwerkeinstellungen anwenden",
|
||||||
|
"pt": "Aplicar definições de rede",
|
||||||
|
"pl": "Zastosuj ustawienia sieci",
|
||||||
|
"nl": "Netwerkinstellingen toepassen",
|
||||||
|
"ru": "Применить настройки сети"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relay_applying": {
|
||||||
|
"context": "Network settings button label while a relay configuration is being activated.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Applying…",
|
||||||
|
"fr": "Application…",
|
||||||
|
"es": "Aplicando…",
|
||||||
|
"it": "Applicazione…",
|
||||||
|
"de": "Wird angewendet…",
|
||||||
|
"pt": "A aplicar…",
|
||||||
|
"pl": "Stosowanie…",
|
||||||
|
"nl": "Toepassen…",
|
||||||
|
"ru": "Применение…"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relay_apply_active_transfers": {
|
||||||
|
"context": "Network settings warning when relay configuration cannot change during active work.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Stop all active transfers and shares before applying network settings.",
|
||||||
|
"fr": "Arrêtez tous les transferts et partages actifs avant d’appliquer les réglages réseau.",
|
||||||
|
"es": "Detenga todas las transferencias y elementos compartidos activos antes de aplicar los ajustes de red.",
|
||||||
|
"it": "Interrompa tutti i trasferimenti e le condivisioni attivi prima di applicare le impostazioni di rete.",
|
||||||
|
"de": "Beenden Sie alle aktiven Übertragungen und Freigaben, bevor Sie die Netzwerkeinstellungen anwenden.",
|
||||||
|
"pt": "Pare todas as transferências e partilhas ativas antes de aplicar as definições de rede.",
|
||||||
|
"pl": "Zatrzymaj wszystkie aktywne transfery i udostępnienia przed zastosowaniem ustawień sieci.",
|
||||||
|
"nl": "Stop alle actieve overdrachten en gedeelde items voordat u de netwerkinstellingen toepast.",
|
||||||
|
"ru": "Остановите все активные передачи и раздачи перед применением настроек сети."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relay_apply_failed": {
|
||||||
|
"context": "Network settings error after a relay configuration fails and the previous one is restored.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Could not apply these settings. The previous network settings were restored.",
|
||||||
|
"fr": "Impossible d’appliquer ces réglages. Les réglages réseau précédents ont été restaurés.",
|
||||||
|
"es": "No se han podido aplicar estos ajustes. Se han restaurado los ajustes de red anteriores.",
|
||||||
|
"it": "Impossibile applicare queste impostazioni. Sono state ripristinate le impostazioni di rete precedenti.",
|
||||||
|
"de": "Diese Einstellungen konnten nicht angewendet werden. Die vorherigen Netzwerkeinstellungen wurden wiederhergestellt.",
|
||||||
|
"pt": "Não foi possível aplicar estas definições. As definições de rede anteriores foram restauradas.",
|
||||||
|
"pl": "Nie udało się zastosować tych ustawień. Przywrócono poprzednie ustawienia sieci.",
|
||||||
|
"nl": "Deze instellingen konden niet worden toegepast. De vorige netwerkinstellingen zijn hersteld.",
|
||||||
|
"ru": "Не удалось применить эти настройки. Предыдущие настройки сети восстановлены."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relay_apply_restart_description": {
|
||||||
|
"context": "Network settings explanation of restart and invitation effects when applying relay changes.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Applying restarts VniDrop’s network connection. Stop active transfers and shares first. Existing invitations may need to be shared again.",
|
||||||
|
"fr": "L’application de ces réglages redémarre la connexion réseau de VniDrop. Arrêtez d’abord les transferts et partages actifs. Il peut être nécessaire de partager à nouveau les invitations existantes.",
|
||||||
|
"es": "Al aplicar los ajustes, se reinicia la conexión de red de VniDrop. Detenga primero las transferencias y los elementos compartidos activos. Es posible que tenga que volver a compartir las invitaciones existentes.",
|
||||||
|
"it": "L’applicazione riavvia la connessione di rete di VniDrop. Interrompa prima i trasferimenti e le condivisioni attivi. Potrebbe essere necessario condividere di nuovo gli inviti esistenti.",
|
||||||
|
"de": "Beim Anwenden wird die Netzwerkverbindung von VniDrop neu gestartet. Beenden Sie zuerst aktive Übertragungen und Freigaben. Vorhandene Einladungen müssen eventuell erneut geteilt werden.",
|
||||||
|
"pt": "A aplicação reinicia a ligação de rede do VniDrop. Pare primeiro as transferências e partilhas ativas. Poderá ser necessário voltar a partilhar os convites existentes.",
|
||||||
|
"pl": "Zastosowanie ustawień ponownie uruchamia połączenie sieciowe VniDrop. Najpierw zatrzymaj aktywne transfery i udostępnienia. Istniejące zaproszenia mogą wymagać ponownego udostępnienia.",
|
||||||
|
"nl": "Bij het toepassen wordt de netwerkverbinding van VniDrop opnieuw gestart. Stop eerst actieve overdrachten en gedeelde items. Bestaande uitnodigingen moeten mogelijk opnieuw worden gedeeld.",
|
||||||
|
"ru": "При применении сетевое соединение VniDrop перезапускается. Сначала остановите активные передачи и раздачи. Возможно, существующие приглашения потребуется отправить повторно."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relay_custom_urls_help": {
|
||||||
|
"context": "Network settings help for entering custom relay server URLs.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Enter one HTTPS relay URL per line. URL credentials are not supported. The TLS certificate must be issued by a publicly trusted certificate authority.",
|
||||||
|
"fr": "Saisissez une URL de relais HTTPS par ligne. Les identifiants dans les URL ne sont pas pris en charge. Le certificat TLS doit être émis par une autorité de certification reconnue publiquement.",
|
||||||
|
"es": "Introduzca una URL HTTPS de relé por línea. No se admiten credenciales en las URL. El certificado TLS debe ser emitido por una autoridad de certificación de confianza pública.",
|
||||||
|
"it": "Inserisca un URL relay HTTPS per riga. Le credenziali negli URL non sono supportate. Il certificato TLS deve essere emesso da un’autorità di certificazione pubblicamente attendibile.",
|
||||||
|
"de": "Geben Sie pro Zeile eine HTTPS-Relay-URL ein. Anmeldedaten in URLs werden nicht unterstützt. Das TLS-Zertifikat muss von einer öffentlich vertrauenswürdigen Zertifizierungsstelle ausgestellt sein.",
|
||||||
|
"pt": "Introduza um URL HTTPS de retransmissor por linha. Não são suportadas credenciais nos URLs. O certificado TLS tem de ser emitido por uma autoridade de certificação publicamente reconhecida.",
|
||||||
|
"pl": "Wprowadź po jednym adresie URL HTTPS przekaźnika w każdym wierszu. Dane logowania w adresach URL nie są obsługiwane. Certyfikat TLS musi być wystawiony przez publicznie zaufany urząd certyfikacji.",
|
||||||
|
"nl": "Voer per regel één HTTPS-relay-URL in. Aanmeldgegevens in URL's worden niet ondersteund. Het TLS-certificaat moet zijn uitgegeven door een openbaar vertrouwde certificeringsinstantie.",
|
||||||
|
"ru": "Введите по одному HTTPS-адресу ретранслятора в строке. Учётные данные в URL-адресах не поддерживаются. Сертификат TLS должен быть выдан общедоступным доверенным центром сертификации."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relay_custom_urls_label": {
|
||||||
|
"context": "Network settings label for the custom relay URL input.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Relay URLs",
|
||||||
|
"fr": "URL des relais",
|
||||||
|
"es": "URL de relés",
|
||||||
|
"it": "URL relay",
|
||||||
|
"de": "Relay-URLs",
|
||||||
|
"pt": "URLs dos retransmissores",
|
||||||
|
"pl": "Adresy URL przekaźników",
|
||||||
|
"nl": "Relay-URL's",
|
||||||
|
"ru": "URL-адреса ретрансляторов"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relay_mode_automatic": {
|
||||||
|
"context": "Network settings label for VniDrop's automatic public relay mode.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Automatic (recommended)",
|
||||||
|
"fr": "Automatique (recommandé)",
|
||||||
|
"es": "Automático (recomendado)",
|
||||||
|
"it": "Automatica (consigliata)",
|
||||||
|
"de": "Automatisch (empfohlen)",
|
||||||
|
"pt": "Automático (recomendado)",
|
||||||
|
"pl": "Automatyczny (zalecany)",
|
||||||
|
"nl": "Automatisch (aanbevolen)",
|
||||||
|
"ru": "Автоматически (рекомендуется)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relay_mode_automatic_description": {
|
||||||
|
"context": "Network settings description of automatic public relay behavior.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Use VniDrop’s default public relay infrastructure when a direct connection is unavailable.",
|
||||||
|
"fr": "Utiliser l’infrastructure de relais publique par défaut de VniDrop lorsqu’une connexion directe est indisponible.",
|
||||||
|
"es": "Usa la infraestructura pública de relés predeterminada de VniDrop cuando no haya una conexión directa disponible.",
|
||||||
|
"it": "Usa l’infrastruttura relay pubblica predefinita di VniDrop quando non è disponibile una connessione diretta.",
|
||||||
|
"de": "Verwendet die öffentliche Standard-Relay-Infrastruktur von VniDrop, wenn keine direkte Verbindung möglich ist.",
|
||||||
|
"pt": "Utiliza a infraestrutura pública de retransmissores predefinida do VniDrop quando não está disponível uma ligação direta.",
|
||||||
|
"pl": "Używa domyślnej publicznej infrastruktury przekaźników VniDrop, gdy połączenie bezpośrednie jest niedostępne.",
|
||||||
|
"nl": "Gebruikt de standaard openbare relay-infrastructuur van VniDrop wanneer geen directe verbinding beschikbaar is.",
|
||||||
|
"ru": "Использовать стандартную публичную инфраструктуру ретрансляторов VniDrop, если прямое соединение недоступно."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relay_mode_custom": {
|
||||||
|
"context": "Network settings label for strict custom relay mode.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Strict custom",
|
||||||
|
"fr": "Personnalisé strict",
|
||||||
|
"es": "Personalizado estricto",
|
||||||
|
"it": "Personalizzata rigorosa",
|
||||||
|
"de": "Strikt benutzerdefiniert",
|
||||||
|
"pt": "Personalizado estrito",
|
||||||
|
"pl": "Ścisły niestandardowy",
|
||||||
|
"nl": "Strikt aangepast",
|
||||||
|
"ru": "Строго пользовательский"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relay_mode_custom_description": {
|
||||||
|
"context": "Network settings description of strict custom relay behavior.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Use only the configured custom relays or direct connections. Report an error if no custom relay can be established.",
|
||||||
|
"fr": "Utilise uniquement les relais personnalisés configurés ou les connexions directes. Signale une erreur si aucun relais personnalisé ne peut être établi.",
|
||||||
|
"es": "Usa solo los relés personalizados configurados o conexiones directas. Informa de un error si no se puede establecer ningún relé personalizado.",
|
||||||
|
"it": "Usa solo i relay personalizzati configurati o connessioni dirette. Segnala un errore se non è possibile stabilire alcun relay personalizzato.",
|
||||||
|
"de": "Verwendet nur die konfigurierten eigenen Relays oder Direktverbindungen. Meldet einen Fehler, wenn kein eigenes Relay erreichbar ist.",
|
||||||
|
"pt": "Utiliza apenas os retransmissores personalizados configurados ou ligações diretas. Apresenta um erro se não for possível estabelecer nenhum retransmissor personalizado.",
|
||||||
|
"pl": "Używa tylko skonfigurowanych własnych przekaźników lub połączeń bezpośrednich. Zgłasza błąd, jeśli nie można połączyć się z żadnym własnym przekaźnikiem.",
|
||||||
|
"nl": "Gebruikt alleen de ingestelde aangepaste relays of rechtstreekse verbindingen. Meldt een fout als geen aangepaste relay bereikbaar is.",
|
||||||
|
"ru": "Использует только настроенные пользовательские ретрансляторы или прямые соединения. Сообщает об ошибке, если ни один пользовательский ретранслятор недоступен."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relay_mode_custom_direct_fallback": {
|
||||||
|
"context": "Network settings label for custom relays that allow direct-only startup fallback.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Custom with direct fallback",
|
||||||
|
"fr": "Personnalisé avec repli direct",
|
||||||
|
"es": "Personalizado con conexión directa de reserva",
|
||||||
|
"it": "Personalizzata con ripiego diretto",
|
||||||
|
"de": "Benutzerdefiniert mit direktem Rückfall",
|
||||||
|
"pt": "Personalizado com alternativa direta",
|
||||||
|
"pl": "Niestandardowy z trybem bezpośrednim",
|
||||||
|
"nl": "Aangepast met directe terugval",
|
||||||
|
"ru": "Пользовательский с прямым резервом"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relay_mode_custom_direct_fallback_description": {
|
||||||
|
"context": "Network settings description of custom relays with direct-only startup fallback.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Prefer the configured custom relays. If unavailable, continue with direct connections only. Never use public relays.",
|
||||||
|
"fr": "Préfère les relais personnalisés configurés. S’ils sont indisponibles, continue uniquement avec des connexions directes. N’utilise jamais les relais publics.",
|
||||||
|
"es": "Prefiere los relés personalizados configurados. Si no están disponibles, continúa solo con conexiones directas. Nunca usa relés públicos.",
|
||||||
|
"it": "Preferisce i relay personalizzati configurati. Se non sono disponibili, continua solo con connessioni dirette. Non usa mai relay pubblici.",
|
||||||
|
"de": "Bevorzugt die konfigurierten eigenen Relays. Sind sie nicht verfügbar, werden nur Direktverbindungen verwendet. Öffentliche Relays werden nie genutzt.",
|
||||||
|
"pt": "Prefere os retransmissores personalizados configurados. Se não estiverem disponíveis, continua apenas com ligações diretas. Nunca utiliza retransmissores públicos.",
|
||||||
|
"pl": "Preferuje skonfigurowane własne przekaźniki. Jeśli są niedostępne, kontynuuje tylko przez połączenia bezpośrednie. Nigdy nie używa publicznych przekaźników.",
|
||||||
|
"nl": "Geeft de voorkeur aan de ingestelde aangepaste relays. Als die niet beschikbaar zijn, worden alleen rechtstreekse verbindingen gebruikt. Openbare relays worden nooit gebruikt.",
|
||||||
|
"ru": "Предпочитает настроенные пользовательские ретрансляторы. Если они недоступны, продолжает работу только через прямые соединения. Публичные ретрансляторы не используются."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relay_mode_local_only": {
|
||||||
|
"context": "Network settings label for direct connections without any relay.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Local only",
|
||||||
|
"fr": "Réseau local uniquement",
|
||||||
|
"es": "Solo red local",
|
||||||
|
"it": "Solo rete locale",
|
||||||
|
"de": "Nur lokales Netzwerk",
|
||||||
|
"pt": "Apenas rede local",
|
||||||
|
"pl": "Tylko sieć lokalna",
|
||||||
|
"nl": "Alleen lokaal netwerk",
|
||||||
|
"ru": "Только локальная сеть"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relay_mode_local_only_description": {
|
||||||
|
"context": "Network settings description of direct-only local-network mode.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Disable all relays and allow only direct connections, primarily for devices on the same network.",
|
||||||
|
"fr": "Désactive tous les relais et autorise uniquement les connexions directes, principalement pour les appareils sur le même réseau.",
|
||||||
|
"es": "Desactiva todos los relés y permite solo conexiones directas, principalmente para dispositivos de la misma red.",
|
||||||
|
"it": "Disattiva tutti i relay e consente solo connessioni dirette, soprattutto per dispositivi sulla stessa rete.",
|
||||||
|
"de": "Deaktiviert alle Relays und erlaubt nur Direktverbindungen, hauptsächlich für Geräte im selben Netzwerk.",
|
||||||
|
"pt": "Desativa todos os retransmissores e permite apenas ligações diretas, principalmente para dispositivos na mesma rede.",
|
||||||
|
"pl": "Wyłącza wszystkie przekaźniki i zezwala tylko na połączenia bezpośrednie, głównie dla urządzeń w tej samej sieci.",
|
||||||
|
"nl": "Schakelt alle relays uit en staat alleen rechtstreekse verbindingen toe, vooral voor apparaten in hetzelfde netwerk.",
|
||||||
|
"ru": "Отключает все ретрансляторы и разрешает только прямые соединения, прежде всего для устройств в одной сети."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relay_privacy_description": {
|
||||||
|
"context": "Network settings privacy note about what relay operators can observe.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Relays forward encrypted traffic and cannot read your files, but their operator can observe connection metadata.",
|
||||||
|
"fr": "Les relais transmettent du trafic chiffré et ne peuvent pas lire vos fichiers, mais leur opérateur peut observer les métadonnées de connexion.",
|
||||||
|
"es": "Los relés reenvían tráfico cifrado y no pueden leer sus archivos, pero su operador puede observar los metadatos de conexión.",
|
||||||
|
"it": "I relay inoltrano traffico cifrato e non possono leggere i suoi file, ma il loro operatore può osservare i metadati di connessione.",
|
||||||
|
"de": "Relays leiten verschlüsselten Datenverkehr weiter und können Ihre Dateien nicht lesen, ihr Betreiber kann jedoch Verbindungsmetadaten sehen.",
|
||||||
|
"pt": "Os retransmissores encaminham tráfego cifrado e não conseguem ler os seus ficheiros, mas o operador pode observar metadados da ligação.",
|
||||||
|
"pl": "Przekaźniki przesyłają zaszyfrowany ruch i nie mogą odczytać plików, ale ich operator może obserwować metadane połączenia.",
|
||||||
|
"nl": "Relays sturen versleuteld verkeer door en kunnen uw bestanden niet lezen, maar de beheerder kan verbindingsmetadata bekijken.",
|
||||||
|
"ru": "Ретрансляторы передают зашифрованный трафик и не могут читать ваши файлы, но их оператор может видеть метаданные соединения."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relay_remove_url": {
|
||||||
|
"context": "Apple Network settings accessibility label for removing one custom relay URL field.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Remove relay server",
|
||||||
|
"fr": "Supprimer le serveur relais",
|
||||||
|
"es": "Eliminar servidor de retransmisión",
|
||||||
|
"it": "Rimuovi server relay",
|
||||||
|
"de": "Relay-Server entfernen",
|
||||||
|
"pt": "Remover servidor de retransmissão",
|
||||||
|
"pl": "Usuń serwer przekaźnikowy",
|
||||||
|
"nl": "Relayserver verwijderen",
|
||||||
|
"ru": "Удалить сервер-ретранслятор"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relay_restore_failed": {
|
||||||
|
"context": "Network settings severe error when neither new nor previous relay settings can initialize.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Could not restore the previous network settings. Restart VniDrop and review your relay configuration.",
|
||||||
|
"fr": "Impossible de restaurer les réglages réseau précédents. Redémarrez VniDrop et vérifiez votre configuration de relais.",
|
||||||
|
"es": "No se han podido restaurar los ajustes de red anteriores. Reinicie VniDrop y revise la configuración de relés.",
|
||||||
|
"it": "Impossibile ripristinare le impostazioni di rete precedenti. Riavvii VniDrop e verifichi la configurazione dei relay.",
|
||||||
|
"de": "Die vorherigen Netzwerkeinstellungen konnten nicht wiederhergestellt werden. Starten Sie VniDrop neu und prüfen Sie Ihre Relay-Konfiguration.",
|
||||||
|
"pt": "Não foi possível restaurar as definições de rede anteriores. Reinicie o VniDrop e reveja a configuração dos retransmissores.",
|
||||||
|
"pl": "Nie udało się przywrócić poprzednich ustawień sieci. Uruchom ponownie VniDrop i sprawdź konfigurację przekaźników.",
|
||||||
|
"nl": "De vorige netwerkinstellingen konden niet worden hersteld. Start VniDrop opnieuw en controleer uw relayconfiguratie.",
|
||||||
|
"ru": "Не удалось восстановить предыдущие настройки сети. Перезапустите VniDrop и проверьте конфигурацию ретрансляторов."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relay_settings_applied": {
|
||||||
|
"context": "Network settings confirmation after a relay configuration is activated.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Network settings applied.",
|
||||||
|
"fr": "Réglages réseau appliqués.",
|
||||||
|
"es": "Ajustes de red aplicados.",
|
||||||
|
"it": "Impostazioni di rete applicate.",
|
||||||
|
"de": "Netzwerkeinstellungen angewendet.",
|
||||||
|
"pt": "Definições de rede aplicadas.",
|
||||||
|
"pl": "Zastosowano ustawienia sieci.",
|
||||||
|
"nl": "Netwerkinstellingen toegepast.",
|
||||||
|
"ru": "Настройки сети применены."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relay_strict_warning": {
|
||||||
|
"context": "Network settings warning that custom relay mode has no public fallback or discovery.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Strict custom mode will not start unless at least one configured relay is reachable. VniDrop never uses public relays or public discovery in this mode.",
|
||||||
|
"fr": "Le mode personnalisé strict ne démarre que si au moins un relais configuré est accessible. VniDrop n’utilise jamais de relais ni de découverte publics dans ce mode.",
|
||||||
|
"es": "El modo personalizado estricto no se inicia a menos que se pueda acceder al menos a un relé configurado. VniDrop nunca usa relés ni descubrimiento públicos en este modo.",
|
||||||
|
"it": "La modalità personalizzata rigorosa si avvia solo se almeno un relay configurato è raggiungibile. In questa modalità VniDrop non usa mai relay o rilevamento pubblici.",
|
||||||
|
"de": "Der strikt benutzerdefinierte Modus startet nur, wenn mindestens ein konfiguriertes Relay erreichbar ist. VniDrop verwendet in diesem Modus nie öffentliche Relays oder öffentliche Erkennung.",
|
||||||
|
"pt": "O modo personalizado estrito só inicia se pelo menos um retransmissor configurado estiver acessível. Neste modo, o VniDrop nunca utiliza retransmissores nem descoberta públicos.",
|
||||||
|
"pl": "Ścisły tryb niestandardowy uruchamia się tylko wtedy, gdy co najmniej jeden skonfigurowany przekaźnik jest dostępny. VniDrop nigdy nie używa w tym trybie publicznych przekaźników ani publicznego wykrywania.",
|
||||||
|
"nl": "De strikt aangepaste modus start alleen als minstens één ingestelde relay bereikbaar is. VniDrop gebruikt in deze modus nooit openbare relays of openbare detectie.",
|
||||||
|
"ru": "Строго пользовательский режим запускается, только если доступен хотя бы один настроенный ретранслятор. В этом режиме VniDrop никогда не использует публичные ретрансляторы или публичное обнаружение."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relay_validation_duplicate_url": {
|
||||||
|
"context": "Network settings validation error for a repeated custom relay URL.",
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "line",
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"translations": {
|
||||||
|
"en": "Relay URL on line {line} duplicates an earlier entry.",
|
||||||
|
"fr": "L’URL de relais à la ligne {line} est identique à une entrée précédente.",
|
||||||
|
"es": "La URL de relé de la línea {line} duplica una entrada anterior.",
|
||||||
|
"it": "L’URL relay alla riga {line} duplica una voce precedente.",
|
||||||
|
"de": "Die Relay-URL in Zeile {line} ist bereits zuvor eingetragen.",
|
||||||
|
"pt": "O URL do retransmissor na linha {line} duplica uma entrada anterior.",
|
||||||
|
"pl": "Adres URL przekaźnika w wierszu {line} powtarza wcześniejszy wpis.",
|
||||||
|
"nl": "De relay-URL op regel {line} is gelijk aan een eerdere invoer.",
|
||||||
|
"ru": "URL-адрес ретранслятора в строке {line} повторяет предыдущую запись."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relay_validation_https_required": {
|
||||||
|
"context": "Network settings validation error when a custom relay URL is not HTTPS.",
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "line",
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"translations": {
|
||||||
|
"en": "Relay URL on line {line} must start with https://.",
|
||||||
|
"fr": "L’URL de relais à la ligne {line} doit commencer par https://.",
|
||||||
|
"es": "La URL de relé de la línea {line} debe empezar por https://.",
|
||||||
|
"it": "L’URL relay alla riga {line} deve iniziare con https://.",
|
||||||
|
"de": "Die Relay-URL in Zeile {line} muss mit https:// beginnen.",
|
||||||
|
"pt": "O URL do retransmissor na linha {line} tem de começar por https://.",
|
||||||
|
"pl": "Adres URL przekaźnika w wierszu {line} musi zaczynać się od https://.",
|
||||||
|
"nl": "De relay-URL op regel {line} moet beginnen met https://.",
|
||||||
|
"ru": "URL-адрес ретранслятора в строке {line} должен начинаться с https://."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relay_validation_invalid_url": {
|
||||||
|
"context": "Network settings validation error for a malformed custom relay URL.",
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "line",
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"translations": {
|
||||||
|
"en": "Relay URL on line {line} is not valid.",
|
||||||
|
"fr": "L’URL de relais à la ligne {line} n’est pas valide.",
|
||||||
|
"es": "La URL de relé de la línea {line} no es válida.",
|
||||||
|
"it": "L’URL relay alla riga {line} non è valido.",
|
||||||
|
"de": "Die Relay-URL in Zeile {line} ist ungültig.",
|
||||||
|
"pt": "O URL do retransmissor na linha {line} não é válido.",
|
||||||
|
"pl": "Adres URL przekaźnika w wierszu {line} jest nieprawidłowy.",
|
||||||
|
"nl": "De relay-URL op regel {line} is ongeldig.",
|
||||||
|
"ru": "URL-адрес ретранслятора в строке {line} недействителен."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relay_validation_missing_url": {
|
||||||
|
"context": "Network settings validation error when custom mode has no relay URL.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Add at least one relay URL.",
|
||||||
|
"fr": "Ajoutez au moins une URL de relais.",
|
||||||
|
"es": "Añada al menos una URL de relé.",
|
||||||
|
"it": "Aggiunga almeno un URL relay.",
|
||||||
|
"de": "Fügen Sie mindestens eine Relay-URL hinzu.",
|
||||||
|
"pt": "Adicione pelo menos um URL de retransmissor.",
|
||||||
|
"pl": "Dodaj co najmniej jeden adres URL przekaźnika.",
|
||||||
|
"nl": "Voeg ten minste één relay-URL toe.",
|
||||||
|
"ru": "Добавьте хотя бы один URL-адрес ретранслятора."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relay_validation_too_many_urls": {
|
||||||
|
"context": "Network settings validation error when too many custom relay URLs are entered.",
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "maximum",
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"translations": {
|
||||||
|
"en": "You can configure up to {maximum} relay servers.",
|
||||||
|
"fr": "Vous pouvez configurer jusqu’à {maximum} serveurs relais.",
|
||||||
|
"es": "Puede configurar hasta {maximum} servidores de retransmisión.",
|
||||||
|
"it": "Può configurare fino a {maximum} server relay.",
|
||||||
|
"de": "Sie können bis zu {maximum} Relay-Server konfigurieren.",
|
||||||
|
"pt": "Pode configurar até {maximum} servidores de retransmissão.",
|
||||||
|
"pl": "Możesz skonfigurować maksymalnie {maximum} serwerów przekaźnikowych.",
|
||||||
|
"nl": "U kunt maximaal {maximum} relayservers instellen.",
|
||||||
|
"ru": "Можно настроить до {maximum} серверов-ретрансляторов."
|
||||||
|
}
|
||||||
|
},
|
||||||
"send_access_anyone": {
|
"send_access_anyone": {
|
||||||
"context": "Send access option: anyone with the invitation can receive.",
|
"context": "Send access option: anyone with the invitation can receive.",
|
||||||
"translations": {
|
"translations": {
|
||||||
@@ -2986,6 +3416,68 @@
|
|||||||
"ru": "Ваши передачи"
|
"ru": "Ваши передачи"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings_advanced_title": {
|
||||||
|
"context": "Settings overview section header for expert configuration.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Advanced",
|
||||||
|
"fr": "Avancé",
|
||||||
|
"es": "Avanzado",
|
||||||
|
"it": "Avanzate",
|
||||||
|
"de": "Erweitert",
|
||||||
|
"pt": "Avançado",
|
||||||
|
"pl": "Zaawansowane",
|
||||||
|
"nl": "Geavanceerd",
|
||||||
|
"ru": "Дополнительно"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings_ios_background_notice_body": {
|
||||||
|
"context": "Settings overview: explains iOS/iPadOS background limits so users don't think the app is broken. Apple platforms only.",
|
||||||
|
"targets": [
|
||||||
|
"apple"
|
||||||
|
],
|
||||||
|
"translations": {
|
||||||
|
"en": "iPhone and iPad limit what apps may do in the background. VniDrop keeps a transfer that's already running alive long enough to finish and notify you after you leave the app, but it can't keep serving or receiving on its own once it's been in the background for a while. For long transfers, keep VniDrop open. On Mac, transfers continue in the background normally.",
|
||||||
|
"fr": "L’iPhone et l’iPad limitent ce que les apps peuvent faire en arrière-plan. VniDrop maintient un transfert déjà en cours assez longtemps pour le terminer et vous avertir après avoir quitté l’app, mais il ne peut pas continuer à envoyer ou recevoir seul une fois resté en arrière-plan un certain temps. Pour les transferts longs, gardez VniDrop ouvert. Sur Mac, les transferts se poursuivent normalement en arrière-plan.",
|
||||||
|
"es": "El iPhone y el iPad limitan lo que las apps pueden hacer en segundo plano. VniDrop mantiene una transferencia ya en curso el tiempo suficiente para terminarla y avisarte tras salir de la app, pero no puede seguir enviando o recibiendo por sí solo cuando lleva un rato en segundo plano. Para transferencias largas, mantén VniDrop abierto. En Mac, las transferencias continúan en segundo plano con normalidad.",
|
||||||
|
"it": "iPhone e iPad limitano ciò che le app possono fare in background. VniDrop mantiene attivo un trasferimento già in corso quanto basta per completarlo e avvisarti dopo che esci dall’app, ma non può continuare a inviare o ricevere da solo dopo un po’ in background. Per i trasferimenti lunghi, tieni VniDrop aperto. Su Mac i trasferimenti proseguono normalmente in background.",
|
||||||
|
"de": "iPhone und iPad schränken ein, was Apps im Hintergrund tun dürfen. VniDrop hält eine bereits laufende Übertragung lange genug am Leben, um sie abzuschließen und dich zu benachrichtigen, nachdem du die App verlässt, kann aber nicht von selbst weiter senden oder empfangen, wenn es länger im Hintergrund war. Lass VniDrop bei langen Übertragungen geöffnet. Auf dem Mac laufen Übertragungen im Hintergrund normal weiter.",
|
||||||
|
"pt": "O iPhone e o iPad limitam o que as apps podem fazer em segundo plano. O VniDrop mantém uma transferência já em curso ativa o tempo suficiente para terminar e notificá-lo depois de sair da app, mas não consegue continuar a enviar ou receber sozinho depois de algum tempo em segundo plano. Para transferências longas, mantenha o VniDrop aberto. No Mac, as transferências continuam normalmente em segundo plano.",
|
||||||
|
"pl": "iPhone i iPad ograniczają to, co aplikacje mogą robić w tle. VniDrop utrzymuje już trwający transfer wystarczająco długo, aby go dokończyć i powiadomić Cię po opuszczeniu aplikacji, ale nie może samodzielnie wysyłać ani odbierać po dłuższym czasie w tle. Przy długich transferach nie zamykaj VniDrop. Na Macu transfery są kontynuowane w tle normalnie.",
|
||||||
|
"nl": "iPhone en iPad beperken wat apps op de achtergrond mogen doen. VniDrop houdt een al lopende overdracht lang genoeg actief om deze te voltooien en je te melden nadat je de app verlaat, maar kan niet zelf blijven verzenden of ontvangen als het al een tijd op de achtergrond is. Houd VniDrop open bij lange overdrachten. Op de Mac gaan overdrachten normaal door op de achtergrond.",
|
||||||
|
"ru": "iPhone и iPad ограничивают действия приложений в фоне. VniDrop удерживает уже идущую передачу достаточно долго, чтобы завершить её и уведомить вас после выхода из приложения, но не может сам продолжать отправку или приём, пробыв некоторое время в фоне. Для долгих передач держите VniDrop открытым. На Mac передачи продолжаются в фоне как обычно."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings_ios_background_notice_title": {
|
||||||
|
"context": "Settings overview: title of the iOS/iPadOS background-limits notice. Apple platforms only.",
|
||||||
|
"targets": [
|
||||||
|
"apple"
|
||||||
|
],
|
||||||
|
"translations": {
|
||||||
|
"en": "Background limits on iPhone & iPad",
|
||||||
|
"fr": "Limites en arrière-plan sur iPhone et iPad",
|
||||||
|
"es": "Límites en segundo plano en iPhone y iPad",
|
||||||
|
"it": "Limiti in background su iPhone e iPad",
|
||||||
|
"de": "Hintergrund-Grenzen auf iPhone & iPad",
|
||||||
|
"pt": "Limites em segundo plano no iPhone e iPad",
|
||||||
|
"pl": "Ograniczenia w tle na iPhonie i iPadzie",
|
||||||
|
"nl": "Achtergrondlimieten op iPhone en iPad",
|
||||||
|
"ru": "Ограничения фона на iPhone и iPad"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings_network_title": {
|
||||||
|
"context": "Settings overview row and Network settings screen title.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Network",
|
||||||
|
"fr": "Réseau",
|
||||||
|
"es": "Red",
|
||||||
|
"it": "Rete",
|
||||||
|
"de": "Netzwerk",
|
||||||
|
"pt": "Rede",
|
||||||
|
"pl": "Sieć",
|
||||||
|
"nl": "Netwerk",
|
||||||
|
"ru": "Сеть"
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings_subtitle": {
|
"settings_subtitle": {
|
||||||
"context": "Settings screen: subtitle summarizing what's configurable.",
|
"context": "Settings screen: subtitle summarizing what's configurable.",
|
||||||
"translations": {
|
"translations": {
|
||||||
@@ -3188,13 +3680,64 @@
|
|||||||
"ru": "Освобождено {size}"
|
"ru": "Освобождено {size}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"storage_clear_transfer_cache": {
|
||||||
|
"context": "Settings > Storage: button that clears cached transfer content (KMP).",
|
||||||
|
"targets": [
|
||||||
|
"kmp"
|
||||||
|
],
|
||||||
|
"translations": {
|
||||||
|
"en": "Clear transfer cache",
|
||||||
|
"fr": "Vider le cache des transferts",
|
||||||
|
"es": "Borrar caché de transferencias",
|
||||||
|
"it": "Svuota cache trasferimenti",
|
||||||
|
"de": "Übertragungscache leeren",
|
||||||
|
"pt": "Limpar cache de transferências",
|
||||||
|
"pl": "Wyczyść pamięć podręczną transferów",
|
||||||
|
"nl": "Overdrachtscache wissen",
|
||||||
|
"ru": "Очистить кэш передач"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storage_clear_transfer_cache_description": {
|
||||||
|
"context": "Settings > Storage: description under the clear-transfer-cache button (KMP).",
|
||||||
|
"targets": [
|
||||||
|
"kmp"
|
||||||
|
],
|
||||||
|
"translations": {
|
||||||
|
"en": "Removes cached transfer content after briefly restarting VniDrop. Finish ongoing transfers and stop active shares first. Received files and transfer history are not deleted.",
|
||||||
|
"fr": "Supprime le contenu de transfert en cache qui n’est pas utilisé par une réception en cours ou un partage actif. Les fichiers reçus et l’historique ne sont pas supprimés.",
|
||||||
|
"es": "Elimina el contenido de transferencia en caché que no esté siendo utilizado por una recepción en curso o un recurso compartido activo. Los archivos recibidos y el historial no se eliminan.",
|
||||||
|
"it": "Rimuove il contenuto dei trasferimenti memorizzato nella cache che non è usato da una ricezione in corso o da una condivisione attiva. I file ricevuti e la cronologia non vengono eliminati.",
|
||||||
|
"de": "Entfernt zwischengespeicherte Übertragungsinhalte, die nicht von einem laufenden Empfang oder einer aktiven Freigabe verwendet werden. Empfangene Dateien und der Übertragungsverlauf werden nicht gelöscht.",
|
||||||
|
"pt": "Remove conteúdo de transferência em cache que não esteja a ser utilizado por uma receção em curso ou partilha ativa. Os ficheiros recebidos e o histórico não são eliminados.",
|
||||||
|
"pl": "Usuwa zawartość transferów z pamięci podręcznej, która nie jest używana przez trwające odbieranie ani aktywne udostępnianie. Odebrane pliki i historia nie są usuwane.",
|
||||||
|
"nl": "Verwijdert overdrachtsinhoud uit de cache die niet wordt gebruikt door een lopende ontvangst of actieve share. Ontvangen bestanden en de overdrachtsgeschiedenis worden niet verwijderd.",
|
||||||
|
"ru": "Удаляет кэшированное содержимое передач, которое не используется текущим приёмом или активной раздачей. Полученные файлы и история передач не удаляются."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storage_clearing_transfer_cache": {
|
||||||
|
"context": "Settings > Storage: clear-transfer-cache button while clearing is in progress (KMP).",
|
||||||
|
"targets": [
|
||||||
|
"kmp"
|
||||||
|
],
|
||||||
|
"translations": {
|
||||||
|
"en": "Clearing cache…",
|
||||||
|
"fr": "Vidage du cache…",
|
||||||
|
"es": "Borrando caché…",
|
||||||
|
"it": "Svuotamento cache…",
|
||||||
|
"de": "Cache wird geleert…",
|
||||||
|
"pt": "A limpar cache…",
|
||||||
|
"pl": "Czyszczenie pamięci podręcznej…",
|
||||||
|
"nl": "Cache wissen…",
|
||||||
|
"ru": "Очистка кэша…"
|
||||||
|
}
|
||||||
|
},
|
||||||
"storage_delete_transfers_caption": {
|
"storage_delete_transfers_caption": {
|
||||||
"context": "Settings > Storage: caption under the destructive delete-all button.",
|
"context": "Settings > Storage: caption under the destructive delete-all button.",
|
||||||
"translations": {
|
"translations": {
|
||||||
"en": "Clears your send and receive history and the app's cached share content. Received files on disk are kept.",
|
"en": "Clears your send and receive history and the app’s cached share content. Received files on disk are kept.",
|
||||||
"fr": "Efface votre historique d'envois et de réceptions ainsi que le contenu de partage mis en cache par l'app. Les fichiers reçus sur le disque sont conservés.",
|
"fr": "Efface votre historique d’envois et de réceptions ainsi que le contenu de partage mis en cache par l’app. Les fichiers reçus sur le disque sont conservés.",
|
||||||
"es": "Borra tu historial de envíos y recepciones y el contenido compartido en caché de la app. Los archivos recibidos en el disco se conservan.",
|
"es": "Borra tu historial de envíos y recepciones y el contenido compartido en caché de la app. Los archivos recibidos en el disco se conservan.",
|
||||||
"it": "Cancella la cronologia di invii e ricezioni e i contenuti di condivisione memorizzati dall'app. I file ricevuti sul disco vengono mantenuti.",
|
"it": "Cancella la cronologia di invii e ricezioni e i contenuti di condivisione memorizzati dall’app. I file ricevuti sul disco vengono mantenuti.",
|
||||||
"de": "Löscht deinen Sende- und Empfangsverlauf sowie die zwischengespeicherten Freigabeinhalte der App. Empfangene Dateien auf dem Datenträger bleiben erhalten.",
|
"de": "Löscht deinen Sende- und Empfangsverlauf sowie die zwischengespeicherten Freigabeinhalte der App. Empfangene Dateien auf dem Datenträger bleiben erhalten.",
|
||||||
"pt": "Limpa o teu histórico de envios e receções e o conteúdo de partilha em cache da app. Os ficheiros recebidos no disco são mantidos.",
|
"pt": "Limpa o teu histórico de envios e receções e o conteúdo de partilha em cache da app. Os ficheiros recebidos no disco são mantidos.",
|
||||||
"pl": "Czyści historię wysyłania i odbierania oraz zapisane w pamięci podręcznej udostępniane treści. Odebrane pliki na dysku zostają zachowane.",
|
"pl": "Czyści historię wysyłania i odbierania oraz zapisane w pamięci podręcznej udostępniane treści. Odebrane pliki na dysku zostają zachowane.",
|
||||||
@@ -3230,6 +3773,23 @@
|
|||||||
"ru": "Обновить"
|
"ru": "Обновить"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"storage_transfer_cache_cleared": {
|
||||||
|
"context": "Settings > Storage: confirmation that the transfer cache was cleared (KMP).",
|
||||||
|
"targets": [
|
||||||
|
"kmp"
|
||||||
|
],
|
||||||
|
"translations": {
|
||||||
|
"en": "Transfer cache cleared",
|
||||||
|
"fr": "Cache des transferts vidé",
|
||||||
|
"es": "Caché de transferencias borrada",
|
||||||
|
"it": "Cache trasferimenti svuotata",
|
||||||
|
"de": "Übertragungscache geleert",
|
||||||
|
"pt": "Cache de transferências limpa",
|
||||||
|
"pl": "Wyczyszczono pamięć podręczną transferów",
|
||||||
|
"nl": "Overdrachtscache gewist",
|
||||||
|
"ru": "Кэш передач очищен"
|
||||||
|
}
|
||||||
|
},
|
||||||
"storage_unavailable": {
|
"storage_unavailable": {
|
||||||
"context": "Settings > Storage: shown when usage couldn't be calculated yet.",
|
"context": "Settings > Storage: shown when usage couldn't be calculated yet.",
|
||||||
"translations": {
|
"translations": {
|
||||||
@@ -3289,15 +3849,15 @@
|
|||||||
"storage_delete_transfers_description": {
|
"storage_delete_transfers_description": {
|
||||||
"context": "Settings > Storage: confirmation body for deleting all transfer records.",
|
"context": "Settings > Storage: confirmation body for deleting all transfer records.",
|
||||||
"translations": {
|
"translations": {
|
||||||
"en": "This clears all sent and received transfer records from your history. Your received files are not deleted. Cached shared content that is no longer needed is reclaimed automatically, which may take a little time. This can’t be undone.",
|
"en": "This clears all sent and received transfer records from your history and immediately reclaims unused transfer cache. Ongoing transfers and received files are not deleted. This can’t be undone.",
|
||||||
"fr": "Cela efface de votre historique tous les enregistrements de transferts envoyés et reçus. Vos fichiers reçus ne sont pas supprimés. Le contenu partagé mis en cache qui n’est plus nécessaire est récupéré automatiquement, ce qui peut prendre un peu de temps. Cette action est irréversible.",
|
"fr": "Cela efface tous les transferts envoyés et reçus de l’historique et libère immédiatement le cache inutilisé. Les transferts en cours et les fichiers reçus ne sont pas supprimés. Cette action est irréversible.",
|
||||||
"es": "Esto borra de su historial todos los registros de transferencias enviadas y recibidas. Sus archivos recibidos no se eliminan. El contenido compartido en caché que ya no se necesita se recupera automáticamente, lo que puede tardar un poco. Esto no se puede deshacer.",
|
"es": "Esto borra del historial todos los registros de transferencias enviadas y recibidas y libera inmediatamente la caché de transferencia no utilizada. Las transferencias en curso y los archivos recibidos no se eliminan. Esto no se puede deshacer.",
|
||||||
"it": "Questo cancella dalla cronologia tutti i record dei trasferimenti inviati e ricevuti. I file ricevuti non vengono eliminati. Il contenuto condiviso nella cache che non serve più viene recuperato automaticamente, operazione che può richiedere un po’ di tempo. Questa azione non può essere annullata.",
|
"it": "Elimina dalla cronologia tutti i trasferimenti inviati e ricevuti e libera immediatamente la cache inutilizzata. I trasferimenti in corso e i file ricevuti non vengono eliminati. Questa azione non può essere annullata.",
|
||||||
"de": "Dadurch werden alle Datensätze gesendeter und empfangener Übertragungen aus Ihrem Verlauf gelöscht. Ihre empfangenen Dateien werden nicht gelöscht. Nicht mehr benötigte zwischengespeicherte freigegebene Inhalte werden automatisch bereinigt; dies kann etwas dauern. Dies kann nicht rückgängig gemacht werden.",
|
"de": "Dadurch werden alle Datensätze gesendeter und empfangener Übertragungen aus Ihrem Verlauf gelöscht und nicht benötigter Übertragungscache sofort freigegeben. Laufende Übertragungen und empfangene Dateien werden nicht gelöscht. Dies kann nicht rückgängig gemacht werden.",
|
||||||
"pt": "Isto elimina do histórico todos os registos de transferências enviadas e recebidas. Os ficheiros recebidos não são eliminados. O conteúdo partilhado em cache que já não é necessário é recuperado automaticamente, o que pode demorar algum tempo. Esta ação não pode ser anulada.",
|
"pt": "Isto elimina do histórico todas as transferências enviadas e recebidas e liberta imediatamente a cache não utilizada. As transferências em curso e os ficheiros recebidos não são eliminados. Esta ação não pode ser anulada.",
|
||||||
"pl": "Spowoduje to usunięcie z historii wszystkich rekordów wysłanych i odebranych transferów. Odebrane pliki nie zostaną usunięte. Niepotrzebna już zawartość udostępniona w pamięci podręcznej jest odzyskiwana automatycznie, co może chwilę potrwać. Tej operacji nie można cofnąć.",
|
"pl": "Usuwa z historii wszystkie wysłane i odebrane transfery oraz natychmiast zwalnia nieużywaną pamięć podręczną. Trwające transfery i odebrane pliki nie są usuwane. Tej operacji nie można cofnąć.",
|
||||||
"nl": "Hiermee worden alle records van verzonden en ontvangen overdrachten uit uw geschiedenis gewist. Uw ontvangen bestanden worden niet verwijderd. Gedeelde inhoud in de cache die niet meer nodig is, wordt automatisch opgeruimd; dit kan enige tijd duren. Dit kan niet ongedaan worden gemaakt.",
|
"nl": "Hiermee worden alle verzonden en ontvangen overdrachten uit de geschiedenis gewist en wordt ongebruikte overdrachtscache direct vrijgemaakt. Lopende overdrachten en ontvangen bestanden worden niet verwijderd. Dit kan niet ongedaan worden gemaakt.",
|
||||||
"ru": "Это удалит из истории все записи об отправленных и полученных передачах. Полученные файлы не удаляются. Кэшированное общее содержимое, которое больше не требуется, освобождается автоматически; это может занять некоторое время. Это действие нельзя отменить."
|
"ru": "Это удалит из истории все отправленные и полученные передачи и немедленно освободит неиспользуемый кэш. Текущие передачи и полученные файлы не удаляются. Это действие нельзя отменить."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage_app_data": {
|
"storage_app_data": {
|
||||||
@@ -3889,6 +4449,20 @@
|
|||||||
"ru": "Запрос истёк"
|
"ru": "Запрос истёк"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"transfer_receiver_failed": {
|
||||||
|
"context": "Receiver status: the delivery to this receiver failed.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Delivery failed",
|
||||||
|
"fr": "Échec de l'envoi",
|
||||||
|
"es": "Error en la entrega",
|
||||||
|
"it": "Consegna non riuscita",
|
||||||
|
"de": "Übertragung fehlgeschlagen",
|
||||||
|
"pt": "Falha na entrega",
|
||||||
|
"pl": "Dostarczenie nie powiodło się",
|
||||||
|
"nl": "Levering mislukt",
|
||||||
|
"ru": "Ошибка доставки"
|
||||||
|
}
|
||||||
|
},
|
||||||
"transfer_receiver_refused": {
|
"transfer_receiver_refused": {
|
||||||
"context": "Receiver status: the request was refused.",
|
"context": "Receiver status: the request was refused.",
|
||||||
"translations": {
|
"translations": {
|
||||||
@@ -3999,6 +4573,20 @@
|
|||||||
"ru": "Получатели"
|
"ru": "Получатели"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"transfer_qr_unavailable": {
|
||||||
|
"context": "Transfer share: shown when an invitation is too large to encode as a QR code.",
|
||||||
|
"translations": {
|
||||||
|
"en": "QR unavailable for this invitation. Use Share or Download instead.",
|
||||||
|
"fr": "Le code QR n’est pas disponible pour cette invitation. Utilisez plutôt Partager ou Télécharger.",
|
||||||
|
"es": "El QR no está disponible para esta invitación. Use Compartir o Descargar.",
|
||||||
|
"it": "Il codice QR non è disponibile per questo invito. Utilizzi invece Condividi o Scarica.",
|
||||||
|
"de": "Für diese Einladung ist kein QR-Code verfügbar. Verwenden Sie stattdessen Teilen oder Herunterladen.",
|
||||||
|
"pt": "O código QR não está disponível para este convite. Utilize Partilhar ou Transferir.",
|
||||||
|
"pl": "Kod QR jest niedostępny dla tego zaproszenia. Zamiast tego użyj opcji Udostępnij lub Pobierz.",
|
||||||
|
"nl": "QR is niet beschikbaar voor deze uitnodiging. Gebruik in plaats daarvan Delen of Downloaden.",
|
||||||
|
"ru": "QR-код недоступен для этого приглашения. Используйте «Поделиться» или «Скачать»."
|
||||||
|
}
|
||||||
|
},
|
||||||
"transfer_scan_qr": {
|
"transfer_scan_qr": {
|
||||||
"context": "Transfer share: caption under the QR code.",
|
"context": "Transfer share: caption under the QR code.",
|
||||||
"translations": {
|
"translations": {
|
||||||
@@ -4041,6 +4629,23 @@
|
|||||||
"ru": "Поделиться"
|
"ru": "Поделиться"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"updates_check": {
|
||||||
|
"context": "macOS app menu item that checks for a new version via Sparkle. Direct-download (.dmg) build only; never shown in the App Store build.",
|
||||||
|
"targets": [
|
||||||
|
"apple"
|
||||||
|
],
|
||||||
|
"translations": {
|
||||||
|
"en": "Check for Updates…",
|
||||||
|
"fr": "Rechercher les mises à jour…",
|
||||||
|
"es": "Buscar actualizaciones…",
|
||||||
|
"it": "Cerca aggiornamenti…",
|
||||||
|
"de": "Nach Updates suchen…",
|
||||||
|
"pt": "Procurar atualizações…",
|
||||||
|
"pl": "Sprawdź aktualizacje…",
|
||||||
|
"nl": "Zoeken naar updates…",
|
||||||
|
"ru": "Проверить наличие обновлений…"
|
||||||
|
}
|
||||||
|
},
|
||||||
"value_unavailable": {
|
"value_unavailable": {
|
||||||
"context": "Placeholder shown when a device-info or metadata value can't be read.",
|
"context": "Placeholder shown when a device-info or metadata value can't be read.",
|
||||||
"translations": {
|
"translations": {
|
||||||
|
|||||||
7
packaging/apple/.gitignore
vendored
Normal file
7
packaging/apple/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Exported screenshots (large; regenerated from the design source) — not tracked.
|
||||||
|
*.jpg
|
||||||
|
*.jpeg
|
||||||
|
|
||||||
|
# macOS / editor junk
|
||||||
|
.DS_Store
|
||||||
|
*~lock~
|
||||||
3
packaging/apple/AppStore.af
Normal file
3
packaging/apple/AppStore.af
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:2aa9a22a914aa9503f202cf2f2336e9dc236a7aa2cb09fc52a6f1ac61fc90ca7
|
||||||
|
size 10653377
|
||||||
54
packaging/homebrew/tap-README.md
Normal file
54
packaging/homebrew/tap-README.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# homebrew-vnidrop
|
||||||
|
|
||||||
|
Homebrew tap for [VniDrop](https://github.com/sudosylabs/vnidrop) — direct,
|
||||||
|
private device-to-device file and folder transfer for macOS.
|
||||||
|
|
||||||
|
> This repo only holds the Homebrew **cask**. The app itself lives at
|
||||||
|
> [sudosylabs/vnidrop](https://github.com/sudosylabs/vnidrop). The cask here is
|
||||||
|
> updated automatically by VniDrop's release pipeline on each tagged release.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
brew tap sudosylabs/vnidrop
|
||||||
|
brew install --cask vnidrop
|
||||||
|
```
|
||||||
|
|
||||||
|
Or in one line:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
brew install --cask sudosylabs/vnidrop/vnidrop
|
||||||
|
```
|
||||||
|
|
||||||
|
## Update
|
||||||
|
|
||||||
|
VniDrop updates itself in-app via [Sparkle](https://sparkle-project.org), so you
|
||||||
|
normally don't need to do anything. To update through Homebrew instead:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
brew upgrade --cask vnidrop
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uninstall
|
||||||
|
|
||||||
|
```sh
|
||||||
|
brew uninstall --cask vnidrop
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `--zap` to also remove VniDrop's application support, cache, and preference
|
||||||
|
files:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
brew uninstall --zap --cask vnidrop
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- macOS 15 (Sequoia) or later, Apple Silicon.
|
||||||
|
|
||||||
|
## What you get
|
||||||
|
|
||||||
|
The cask installs the Developer ID–signed, notarized `VniDrop.app` from the
|
||||||
|
matching [GitHub Release](https://github.com/sudosylabs/vnidrop/releases). App
|
||||||
|
Store users should install from the Mac App Store instead — that build does not
|
||||||
|
include the Sparkle self-updater.
|
||||||
36
packaging/homebrew/vnidrop.rb
Normal file
36
packaging/homebrew/vnidrop.rb
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Homebrew cask for the direct-download (notarized .dmg) macOS build.
|
||||||
|
#
|
||||||
|
# This file is the source template. The Apple release workflow substitutes the
|
||||||
|
# version + sha256 for each release and pushes the result to the tap repo
|
||||||
|
# (sudosylabs/homebrew-vnidrop, path Casks/vnidrop.rb). Users then install with:
|
||||||
|
# brew install --cask sudosylabs/vnidrop/vnidrop
|
||||||
|
#
|
||||||
|
# `auto_updates true` tells Homebrew that the app updates itself via Sparkle, so
|
||||||
|
# `brew upgrade` won't fight the in-app updater.
|
||||||
|
cask "vnidrop" do
|
||||||
|
version "0.0.0"
|
||||||
|
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
|
|
||||||
|
url "https://github.com/sudosylabs/vnidrop/releases/download/v#{version}/VniDrop-#{version}.dmg",
|
||||||
|
verified: "github.com/sudosylabs/vnidrop/"
|
||||||
|
name "VniDrop"
|
||||||
|
desc "Direct device-to-device file and folder transfer over the network"
|
||||||
|
homepage "https://github.com/sudosylabs/vnidrop"
|
||||||
|
|
||||||
|
livecheck do
|
||||||
|
url :url
|
||||||
|
strategy :github_latest
|
||||||
|
end
|
||||||
|
|
||||||
|
auto_updates true
|
||||||
|
depends_on arch: :arm64
|
||||||
|
depends_on macos: ">= :sequoia"
|
||||||
|
|
||||||
|
app "VniDrop.app"
|
||||||
|
|
||||||
|
zap trash: [
|
||||||
|
"~/Library/Application Support/com.vnidrop.app",
|
||||||
|
"~/Library/Caches/com.vnidrop.app",
|
||||||
|
"~/Library/Preferences/com.vnidrop.app.plist",
|
||||||
|
]
|
||||||
|
end
|
||||||
@@ -32,7 +32,7 @@ lists, animation, accessibility:
|
|||||||
| Architecture | Keep **MVVM-style** ViewModels: immutable `*State`, `StateFlow`, **named methods**. Do not force MVI `onEvent` sealed hierarchies unless asked. |
|
| Architecture | Keep **MVVM-style** ViewModels: immutable `*State`, `StateFlow`, **named methods**. Do not force MVI `onEvent` sealed hierarchies unless asked. |
|
||||||
| Structure | Feature packages under `com.vnidrop.app.feature.*`; thin route/wiring + screen/composables. |
|
| Structure | Feature packages under `com.vnidrop.app.feature.*`; thin route/wiring + screen/composables. |
|
||||||
| Theme | Only `LocalVniDropColors` / `VniDropThemeTokens` (`ui/theme/VniDropTheme.kt`). Brand primary light ≈ `#A855F7` (HSL 271, 91%, 65%). |
|
| Theme | Only `LocalVniDropColors` / `VniDropThemeTokens` (`ui/theme/VniDropTheme.kt`). Brand primary light ≈ `#A855F7` (HSL 271, 91%, 65%). |
|
||||||
| Strings | CMP composeResources / `Res.string.*` — not Android `R` in `commonMain`. |
|
| Strings | CMP composeResources / `Res.string.*` — not Android `R` in `commonMain`. `values*/strings.xml` are **generated** from `localization/strings.json` (source of truth) via the loc CLI — add/edit keys there, never in the XML. |
|
||||||
| DI | Follow existing `AppGraph` construction; no unprompted Hilt/Koin migration. |
|
| DI | Follow existing `AppGraph` construction; no unprompted Hilt/Koin migration. |
|
||||||
| Platform | `androidMain` / `jvmMain` for pickers, SAF, NFC/QR, and desktop integration. |
|
| Platform | `androidMain` / `jvmMain` for pickers, SAF, NFC/QR, and desktop integration. |
|
||||||
| Dependencies | Before adding Jetpack/AndroidX to `commonMain`, verify multiplatform artifacts for all targets. |
|
| Dependencies | Before adding Jetpack/AndroidX to `commonMain`, verify multiplatform artifacts for all targets. |
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user