mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-06 18:59:57 +02:00
Compare commits
58 Commits
7cc0e825f6
...
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 | |||
| e22e395efc | |||
| 677c3ce47f | |||
| 20597e6e88 | |||
| 42f569dcf0 | |||
| 8b31c0fe78 | |||
| c9ef40f00f | |||
| 0448137d84 | |||
| 6180cb95ce | |||
| 2b2fe93293 | |||
| 3469b122c2 | |||
| 4074f4bee8 | |||
| 0e0d43bc4d | |||
| 081b59815c | |||
| 3c8267adc5 | |||
| 08e61c57af | |||
| 1563bf80d6 | |||
| ef42875ddb |
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
|
||||
16
.github/workflows/apple.yml
vendored
16
.github/workflows/apple.yml
vendored
@@ -12,6 +12,7 @@ on:
|
||||
- "config.mk"
|
||||
- "make/**"
|
||||
- ".github/workflows/apple.yml"
|
||||
- "localization/**"
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
@@ -25,6 +26,7 @@ on:
|
||||
- "config.mk"
|
||||
- "make/**"
|
||||
- ".github/workflows/apple.yml"
|
||||
- "localization/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -63,5 +65,19 @@ jobs:
|
||||
- name: 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
|
||||
# The Apple l10n catalog (Localizable.xcstrings) and L10n.swift are
|
||||
# generated from localization/strings.json at build time, not tracked.
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Build and test Apple app
|
||||
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
|
||||
output/
|
||||
.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.
|
||||
9. After code changes, run the **relevant** checks in [Build and test](#build-and-test)
|
||||
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
|
||||
- Unsigned commits when signing is required
|
||||
- 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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
3
LICENSE
3
LICENSE
@@ -187,7 +187,8 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
Copyright 2026 VniDrop
|
||||
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
8
Makefile
8
Makefile
@@ -113,7 +113,7 @@ apple-core: ## Build the Rust XCFramework and generated Swift bindings.
|
||||
@test "$(HOST_OS)" = macos || { printf 'Apple builds require macOS.\n' >&2; exit 1; }
|
||||
cd $(ROOT) && apple/scripts/build-core.sh $(APPLE_PROFILE)
|
||||
|
||||
apple-project: apple-core ## Generate the native Apple Xcode project.
|
||||
apple-project: apple-core localization ## Generate the native Apple Xcode project.
|
||||
cd $(ROOT)/apple && $(XCODEGEN) generate
|
||||
|
||||
open-apple-project: apple-project ## Generate and open the native Apple Xcode project.
|
||||
@@ -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).
|
||||
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.
|
||||
@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"
|
||||
|
||||
8
apple/.gitignore
vendored
8
apple/.gitignore
vendored
@@ -3,6 +3,10 @@
|
||||
VnidropCore/vnidrop.xcframework/
|
||||
VnidropCore/Sources/VnidropCore/Vnidrop.swift
|
||||
|
||||
# Generated from localization/strings.json (cd localization && bun run src/cli.ts generate)
|
||||
VniDrop/Resources/Localizable.xcstrings
|
||||
VniDrop/Generated/
|
||||
|
||||
# Generated by XcodeGen from project.yml
|
||||
VniDrop.xcodeproj/
|
||||
|
||||
@@ -14,3 +18,7 @@ Local.xcconfig
|
||||
.swiftpm/
|
||||
DerivedData/
|
||||
*.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-project # generate apple/VniDrop.xcodeproj
|
||||
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 build-apple-ios # unsigned iOS simulator build
|
||||
make build-apple-ios # unsigned iOS simulator app
|
||||
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
|
||||
`APPLE_DESTINATION` to override the automatically selected iOS simulator.
|
||||
Code signing is disabled for the app and test targets; local and CI builds do
|
||||
|
||||
@@ -19,7 +19,6 @@ final class AppPreferencesRepositoryTests: XCTestCase {
|
||||
let repo = AppPreferencesRepository(defaults: defaults(), fallback: fallback())
|
||||
XCTAssertEqual(repo.preferences.username, "Default")
|
||||
XCTAssertEqual(repo.preferences.themeMode, .system)
|
||||
XCTAssertFalse(repo.preferences.notificationsEnabled)
|
||||
XCTAssertEqual(repo.preferences.relayConfiguration, .automatic)
|
||||
}
|
||||
|
||||
@@ -29,7 +28,6 @@ final class AppPreferencesRepositoryTests: XCTestCase {
|
||||
let repo = AppPreferencesRepository(defaults: store, fallback: fb)
|
||||
repo.setUsername("Bob")
|
||||
repo.setThemeMode(.dark)
|
||||
repo.setNotificationsEnabled(true)
|
||||
repo.setReceiveFolder(ReceiveFolder(kind: .iosSecurityScopedUrl, value: "file:///x", displayName: "Custom"))
|
||||
repo.setRelayConfiguration(RelayConfiguration(
|
||||
mode: .strictCustom,
|
||||
@@ -40,7 +38,6 @@ final class AppPreferencesRepositoryTests: XCTestCase {
|
||||
let reloaded = AppPreferencesRepository(defaults: store, fallback: fb)
|
||||
XCTAssertEqual(reloaded.preferences.username, "Bob")
|
||||
XCTAssertEqual(reloaded.preferences.themeMode, .dark)
|
||||
XCTAssertTrue(reloaded.preferences.notificationsEnabled)
|
||||
XCTAssertEqual(reloaded.preferences.receiveFolder.displayName, "Custom")
|
||||
XCTAssertEqual(reloaded.preferences.receiveFolder.kind, .iosSecurityScopedUrl)
|
||||
XCTAssertEqual(reloaded.preferences.relayConfiguration, RelayConfiguration(
|
||||
|
||||
@@ -11,7 +11,6 @@ final class ApprovalCoordinatorTests: XCTestCase {
|
||||
private func makeCoordinator(_ core: FakeCoreGateway) -> ApprovalCoordinator {
|
||||
ApprovalCoordinator(
|
||||
repository: core,
|
||||
preferences: Fixtures.preferences(),
|
||||
notifications: LocalNotificationService(),
|
||||
visibility: AppVisibility(),
|
||||
messages: UiMessageController()
|
||||
|
||||
50
apple/Tests/CoreDispatcherTests.swift
Normal file
50
apple/Tests/CoreDispatcherTests.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
import XCTest
|
||||
@testable import VniDrop
|
||||
|
||||
final class CoreDispatcherTests: XCTestCase {
|
||||
|
||||
/// Regression guard for the receive-cancel deadlock: an interrupt-lane call
|
||||
/// must complete even while the serial lane is occupied by a blocking call.
|
||||
/// With a single shared queue (the old design) the interrupt would be stuck
|
||||
/// behind the blocked `receive`, and this would time out.
|
||||
func testInterruptCompletesWhileSerialLaneIsBlocked() async {
|
||||
let dispatcher = CoreDispatcher()
|
||||
let serialEntered = DispatchSemaphore(value: 0)
|
||||
let releaseSerial = DispatchSemaphore(value: 0)
|
||||
|
||||
// Occupy the serial lane with a call that blocks until we release it.
|
||||
let serialTask = Task {
|
||||
await dispatcher.run {
|
||||
serialEntered.signal()
|
||||
releaseSerial.wait()
|
||||
}
|
||||
}
|
||||
XCTAssertEqual(serialEntered.wait(timeout: .now() + 2), .success, "serial lane never started")
|
||||
|
||||
// The interrupt lane must run despite the serial lane being blocked.
|
||||
let interruptDone = DispatchSemaphore(value: 0)
|
||||
Task.detached {
|
||||
_ = await dispatcher.runInterrupt { 42 }
|
||||
interruptDone.signal()
|
||||
}
|
||||
XCTAssertEqual(
|
||||
interruptDone.wait(timeout: .now() + 2), .success,
|
||||
"interrupt lane was blocked behind the occupied serial lane")
|
||||
|
||||
releaseSerial.signal()
|
||||
_ = await serialTask.value
|
||||
}
|
||||
|
||||
func testRunPropagatesValuesAndErrors() async {
|
||||
let dispatcher = CoreDispatcher()
|
||||
|
||||
let value = await dispatcher.run { 7 }
|
||||
XCTAssertEqual(try? value.get(), 7)
|
||||
|
||||
let failure = await dispatcher.run { () -> Int in throw TestError.unimplemented }
|
||||
switch failure {
|
||||
case .success: XCTFail("expected the thrown error to propagate")
|
||||
case .failure(let error): XCTAssertTrue(error is TestError)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ final class ProgressDerivationTests: XCTestCase {
|
||||
event(phase: "import", kind: "started", json: "{}"),
|
||||
]
|
||||
let progress = progressForTransfer(events: events, transferId: 1)
|
||||
XCTAssertEqual(progress?.labelKey, "progress_preparing")
|
||||
XCTAssertEqual(progress?.labelKey, L10n.Progress.preparing)
|
||||
XCTAssertEqual(progress?.progress, 0.3)
|
||||
}
|
||||
|
||||
@@ -57,15 +57,15 @@ final class ProgressDerivationTests: XCTestCase {
|
||||
remoteEndpointId: "peer-a",
|
||||
totalSizeHint: 100
|
||||
)
|
||||
XCTAssertEqual(progress?.kind, "completed")
|
||||
XCTAssertEqual(progress?.labelKey, "progress_completed")
|
||||
XCTAssertEqual(progress?.kind, .completed)
|
||||
XCTAssertEqual(progress?.labelKey, L10n.Progress.completed)
|
||||
XCTAssertEqual(progress?.progress, 1)
|
||||
}
|
||||
|
||||
func testStatusLabelKeys() {
|
||||
XCTAssertEqual(statusLabelKey(.sharing), "status_available")
|
||||
XCTAssertEqual(statusLabelKey(.receiving), "status_receiving")
|
||||
XCTAssertEqual(statusLabelKey(.done), "status_completed")
|
||||
XCTAssertEqual(statusLabelKey(.sharing), L10n.Status.available)
|
||||
XCTAssertEqual(statusLabelKey(.receiving), L10n.Status.receiving)
|
||||
XCTAssertEqual(statusLabelKey(.done), L10n.Status.completed)
|
||||
}
|
||||
|
||||
private func event(phase: String, kind: String, json: String) -> CoreEventModel {
|
||||
|
||||
@@ -24,6 +24,9 @@ final class ReceiveModelTests: XCTestCase {
|
||||
XCTAssertEqual(model.state.historyDeleteTarget, .transfer(transferId: 5))
|
||||
|
||||
model.confirmHistoryDelete()
|
||||
// Must close immediately (not after the async delete) so the alert can't
|
||||
// re-present on macOS.
|
||||
XCTAssertNil(model.state.historyDeleteTarget)
|
||||
await waitUntil { core.deletedTransfers.contains(5) }
|
||||
XCTAssertEqual(core.deletedTransfers, [5])
|
||||
XCTAssertNil(model.state.historyDeleteTarget)
|
||||
|
||||
@@ -32,6 +32,9 @@ final class SendModelTests: XCTestCase {
|
||||
XCTAssertTrue(model.state.isDeleteConfirmationOpen)
|
||||
|
||||
model.confirmDeleteTransfer()
|
||||
// Must close immediately (not after the async delete) so the alert can't
|
||||
// re-present on macOS.
|
||||
XCTAssertFalse(model.state.isDeleteConfirmationOpen)
|
||||
await waitUntil { core.deletedTransfers.contains(3) }
|
||||
XCTAssertEqual(core.deletedTransfers, [3])
|
||||
XCTAssertNil(model.state.selectedTransferId)
|
||||
|
||||
51
apple/Tests/TransferNotificationTests.swift
Normal file
51
apple/Tests/TransferNotificationTests.swift
Normal file
@@ -0,0 +1,51 @@
|
||||
import XCTest
|
||||
@testable import VniDrop
|
||||
|
||||
@MainActor
|
||||
final class TransferNotificationTests: XCTestCase {
|
||||
|
||||
func testTransferNotificationsFireForTerminalStatesOnly() {
|
||||
let transfers = [
|
||||
Fixtures.transfer(id: 1, direction: .send, status: .failed),
|
||||
Fixtures.transfer(id: 2, direction: .receive, status: .done),
|
||||
Fixtures.transfer(id: 3, direction: .receive, status: .failed),
|
||||
Fixtures.transfer(id: 4, direction: .receive, status: .receiving), // in-flight, ignored
|
||||
Fixtures.transfer(id: 5, direction: .send, status: .sharing), // active share, ignored
|
||||
Fixtures.transfer(id: 6, direction: .send, status: .done), // send-done isn't notified
|
||||
]
|
||||
let planned = plannedTransferNotifications(transfers, published: [])
|
||||
XCTAssertEqual(planned.map(\.kind), [.sendFailed, .receiveCompleted, .receiveFailed])
|
||||
XCTAssertEqual(planned.map(\.id), ["send-failed-1", "receive-completed-2", "receive-failed-3"])
|
||||
XCTAssertEqual(planned.first?.transferName, "Photos")
|
||||
}
|
||||
|
||||
func testTransferNotificationsSkipAlreadyPublished() {
|
||||
let transfers = [Fixtures.transfer(id: 2, direction: .receive, status: .done)]
|
||||
XCTAssertTrue(plannedTransferNotifications(transfers, published: ["receive-completed-2"]).isEmpty)
|
||||
}
|
||||
|
||||
func testReceiverNotificationsFireOnlyForCompletedReceivers() {
|
||||
let requests = [
|
||||
Fixtures.request(id: "a", requestedAt: 1, status: .completed),
|
||||
Fixtures.request(id: "b", requestedAt: 2, status: .accepted),
|
||||
Fixtures.request(id: "c", requestedAt: 3, status: .requested),
|
||||
]
|
||||
let planned = plannedReceiverNotifications(requests, published: [])
|
||||
XCTAssertEqual(planned.map(\.id), ["receiver-completed-a"])
|
||||
XCTAssertEqual(planned.first?.kind, .receiverCompleted)
|
||||
XCTAssertEqual(planned.first?.receiver, "Peer")
|
||||
XCTAssertEqual(planned.first?.transferName, "Photos")
|
||||
}
|
||||
|
||||
func testReceiverNotificationsSkipAlreadyPublished() {
|
||||
let requests = [Fixtures.request(id: "a", requestedAt: 1, status: .completed)]
|
||||
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() {
|
||||
let c = UiMessageController()
|
||||
c.error(InvitationError.message("QR scanning was cancelled"))
|
||||
c.error(InvitationError.cancelled)
|
||||
XCTAssertNil(c.current) // cancellations are swallowed
|
||||
}
|
||||
|
||||
func testErrorShowsNonCancellation() {
|
||||
let c = UiMessageController()
|
||||
c.error(InvitationError.message("The transfer was refused"))
|
||||
c.error(InvitationError.raw("The transfer was refused"))
|
||||
XCTAssertEqual(c.current?.tone, .error)
|
||||
}
|
||||
}
|
||||
@@ -36,28 +36,31 @@ final class UiMessageControllerTests: XCTestCase {
|
||||
final class UserFacingErrorTests: XCTestCase {
|
||||
|
||||
func testIsUserCancellation() {
|
||||
XCTAssertTrue(InvitationError.message("NFC reading was cancelled").isUserCancellation)
|
||||
XCTAssertTrue(InvitationError.message("User canceled the picker").isUserCancellation)
|
||||
XCTAssertFalse(InvitationError.message("A database error occurred").isUserCancellation)
|
||||
XCTAssertTrue(InvitationError.cancelled.isUserCancellation)
|
||||
XCTAssertTrue(InvitationError.raw("User canceled the picker").isUserCancellation)
|
||||
XCTAssertFalse(InvitationError.raw("A database error occurred").isUserCancellation)
|
||||
}
|
||||
|
||||
func testToUiTextMapsKnownReasons() {
|
||||
XCTAssertEqual(InvitationError.message("The transfer was refused").toUiText(), .resource("error_permission"))
|
||||
XCTAssertEqual(InvitationError.message("invalid ticket").toUiText(), .resource("error_invalid_ticket"))
|
||||
XCTAssertEqual(InvitationError.message("Select at least one file to share").toUiText(), .resource("error_share_empty"))
|
||||
XCTAssertEqual(InvitationError.message("Camera access is required").toUiText(), .resource("error_camera"))
|
||||
// Typed cases map directly at the UI boundary.
|
||||
XCTAssertEqual(InvitationError.shareEmpty.toUiText(), .resource(L10n.Error.shareEmpty))
|
||||
XCTAssertEqual(InvitationError.cameraUnavailable.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() {
|
||||
XCTAssertEqual(InvitationError.message("something entirely unexpected").toUiText(), .resource("error_generic"))
|
||||
XCTAssertEqual(InvitationError.raw("something entirely unexpected").toUiText(), .resource(L10n.Error.generic))
|
||||
}
|
||||
|
||||
func testToUiTextMapsTypedTransferFailures() {
|
||||
XCTAssertEqual(VnidropError.FilesystemPermission(reason: "read-only folder").toUiText(), .resource("error_filesystem"))
|
||||
XCTAssertEqual(VnidropError.DestinationExists(reason: "target exists").toUiText(), .resource("error_destination_exists"))
|
||||
XCTAssertEqual(VnidropError.StorageFull(reason: "disk full").toUiText(), .resource("error_storage_full"))
|
||||
XCTAssertEqual(VnidropError.Network(reason: "offline").toUiText(), .resource("error_network"))
|
||||
XCTAssertEqual(VnidropError.InvalidInput(reason: "bad path").toUiText(), .resource("error_invalid_input"))
|
||||
XCTAssertEqual(VnidropError.FilesystemPermission(reason: "read-only folder").toUiText(), .resource(L10n.Error.filesystem))
|
||||
XCTAssertEqual(VnidropError.DestinationExists(reason: "target exists").toUiText(), .resource(L10n.Error.destinationExists))
|
||||
XCTAssertEqual(VnidropError.StorageFull(reason: "disk full").toUiText(), .resource(L10n.Error.storageFull))
|
||||
XCTAssertEqual(VnidropError.Network(reason: "offline").toUiText(), .resource(L10n.Error.network))
|
||||
XCTAssertEqual(VnidropError.InvalidInput(reason: "bad path").toUiText(), .resource(L10n.Error.invalidInput))
|
||||
XCTAssertFalse(VnidropError.FilesystemPermission(reason: "read-only").canRetryWithoutChangingInput)
|
||||
XCTAssertFalse(VnidropError.DestinationExists(reason: "target exists").canRetryWithoutChangingInput)
|
||||
XCTAssertTrue(VnidropError.Network(reason: "offline").canRetryWithoutChangingInput)
|
||||
|
||||
@@ -12,6 +12,8 @@ final class AppGraph: ObservableObject {
|
||||
let preferencesRepository: AppPreferencesRepository
|
||||
let filePreviewRepository: FilePreviewRepository
|
||||
let approvalCoordinator: ApprovalCoordinator
|
||||
let transferNotificationCoordinator: TransferNotificationCoordinator
|
||||
let backgroundActivity: BackgroundActivityController
|
||||
|
||||
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) {
|
||||
self.dependencies = dependencies
|
||||
@@ -23,17 +25,22 @@ final class AppGraph: ObservableObject {
|
||||
username: dependencies.environment.defaultUsername,
|
||||
receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(),
|
||||
themeMode: .system,
|
||||
notificationsEnabled: false,
|
||||
diagnosticsEnabled: false
|
||||
)
|
||||
)
|
||||
self.approvalCoordinator = ApprovalCoordinator(
|
||||
repository: coreRepository,
|
||||
preferences: preferencesRepository,
|
||||
notifications: dependencies.notificationService,
|
||||
visibility: visibility,
|
||||
messages: messages
|
||||
)
|
||||
self.transferNotificationCoordinator = TransferNotificationCoordinator(
|
||||
repository: coreRepository,
|
||||
notifications: dependencies.notificationService,
|
||||
visibility: visibility,
|
||||
messages: messages
|
||||
)
|
||||
self.backgroundActivity = BackgroundActivityController(repository: coreRepository)
|
||||
AppLogger.info("lifecycle", "graph created", ["platform": dependencies.environment.name])
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import SFSafeSymbols
|
||||
import SwiftUI
|
||||
|
||||
/// App root, ported from `App.kt`. Owns the object graph and feature models, wires
|
||||
@@ -13,6 +14,10 @@ struct RootView: View {
|
||||
|
||||
@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) {
|
||||
let graph = AppGraph(dependencies: dependencies)
|
||||
_graph = StateObject(wrappedValue: graph)
|
||||
@@ -57,11 +62,20 @@ struct RootView: View {
|
||||
navigation(windowClass: windowClass)
|
||||
SnackbarHost(controller: messages)
|
||||
ApprovalModalHost(
|
||||
isPresented: $showApproval,
|
||||
state: approvals.state,
|
||||
onAccept: approvals.accept,
|
||||
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)
|
||||
.preferredColorScheme(appModel.themeMode.preferredColorScheme)
|
||||
.environment(\.vniColors, isDark ? .dark : .light)
|
||||
@@ -72,22 +86,43 @@ struct RootView: View {
|
||||
switch phase {
|
||||
case .active:
|
||||
graph.visibility.setForeground(true)
|
||||
graph.backgroundActivity.didBecomeForeground()
|
||||
settingsModel.refreshNotificationPermission()
|
||||
// Reconcile against the durable snapshot: while the window was
|
||||
// unfocused/occluded (common on macOS) live events may not have
|
||||
// rendered, leaving progress/status stale.
|
||||
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)
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
// 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
|
||||
// on macOS.
|
||||
// A pending approval is a blocking modal. Close the sender's detail panel
|
||||
// (e.g. the Share/QR sheet) first, then present the approval sheet — but on
|
||||
// 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
|
||||
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)
|
||||
// macOS keeps `scenePhase == .active` even when the app loses focus, so
|
||||
@@ -111,7 +146,7 @@ struct RootView: View {
|
||||
#if os(macOS)
|
||||
NavigationSplitView {
|
||||
List(AppDestination.allCases, selection: sidebarBinding) { destination in
|
||||
Label(LocalizedStringKey(destination.labelKey), systemImage: destination.systemImage)
|
||||
Label(String(localized: destination.labelKey), systemSymbol: destination.systemSymbol)
|
||||
.tag(destination)
|
||||
}
|
||||
.navigationSplitViewColumnWidth(min: 180, ideal: 200, max: 260)
|
||||
@@ -123,7 +158,7 @@ struct RootView: View {
|
||||
ForEach(AppDestination.allCases) { destination in
|
||||
screen(for: destination, windowClass: windowClass)
|
||||
.tabItem {
|
||||
Label(LocalizedStringKey(destination.labelKey), systemImage: destination.systemImage)
|
||||
Label(String(localized: destination.labelKey), systemSymbol: destination.systemSymbol)
|
||||
}
|
||||
.tag(destination)
|
||||
}
|
||||
@@ -182,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)
|
||||
import UIKit
|
||||
#else
|
||||
|
||||
@@ -1,17 +1,39 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Scene identifier for the single main window.
|
||||
private let mainWindowId = "main"
|
||||
|
||||
/// Native app entry point for iOS, iPadOS, and macOS.
|
||||
/// Opens `.vnd` invitations via `onOpenURL` and routes them to the receive flow.
|
||||
@main
|
||||
struct VniDropApp: App {
|
||||
@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 {
|
||||
WindowGroup {
|
||||
#if os(macOS)
|
||||
// A single-instance `Window` (not `WindowGroup`): the app must never open a
|
||||
// second window. `Window` also drops the ⌘N "New Window" command.
|
||||
Window(Text(verbatim: "VniDrop"), id: mainWindowId) {
|
||||
RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations))
|
||||
.ignoresSafeArea()
|
||||
.onOpenURL(perform: openInvitation)
|
||||
}
|
||||
#if DIRECT_DISTRIBUTION
|
||||
.commands {
|
||||
UpdatesCommands(controller: updater)
|
||||
}
|
||||
#endif
|
||||
#else
|
||||
WindowGroup(id: mainWindowId) {
|
||||
RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations))
|
||||
.ignoresSafeArea()
|
||||
.onOpenURL(perform: openInvitation)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Reads a `.vnd` invitation document under a security scope, enforcing the
|
||||
|
||||
@@ -120,7 +120,6 @@ struct AppPreferences: Equatable {
|
||||
var username: String
|
||||
var receiveFolder: ReceiveFolder
|
||||
var themeMode: ThemeMode
|
||||
var notificationsEnabled: Bool
|
||||
var diagnosticsEnabled: Bool
|
||||
var diagnosticsInstallId: String
|
||||
var relayConfiguration: RelayConfiguration
|
||||
@@ -130,7 +129,6 @@ struct AppPreferencesDefaults {
|
||||
let username: String
|
||||
let receiveFolder: ReceiveFolder
|
||||
let themeMode: ThemeMode
|
||||
var notificationsEnabled: Bool = false
|
||||
var diagnosticsEnabled: Bool = false
|
||||
}
|
||||
|
||||
@@ -147,7 +145,6 @@ final class AppPreferencesRepository: ObservableObject {
|
||||
static let receiveFolderValue = "receive_folder_value"
|
||||
static let receiveFolderDisplayName = "receive_folder_display_name"
|
||||
static let themeMode = "theme_mode"
|
||||
static let notificationsEnabled = "notifications_enabled"
|
||||
static let diagnosticsEnabled = "diagnostics_enabled"
|
||||
static let diagnosticsInstallId = "diagnostics_install_id"
|
||||
static let relayConfiguration = "relay_configuration"
|
||||
@@ -163,14 +160,12 @@ final class AppPreferencesRepository: ObservableObject {
|
||||
let username = (defaults.string(forKey: Key.username)).flatMap { $0.isEmpty ? nil : $0 } ?? fallback.username
|
||||
let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder)
|
||||
let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode
|
||||
let notifications = defaults.object(forKey: Key.notificationsEnabled) as? Bool ?? fallback.notificationsEnabled
|
||||
let diagnostics = defaults.object(forKey: Key.diagnosticsEnabled) as? Bool ?? fallback.diagnosticsEnabled
|
||||
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
|
||||
return AppPreferences(
|
||||
username: username,
|
||||
receiveFolder: folder,
|
||||
themeMode: themeMode,
|
||||
notificationsEnabled: notifications,
|
||||
diagnosticsEnabled: diagnostics,
|
||||
diagnosticsInstallId: installId,
|
||||
relayConfiguration: resolveRelayConfiguration(defaults)
|
||||
@@ -224,11 +219,6 @@ final class AppPreferencesRepository: ObservableObject {
|
||||
reload()
|
||||
}
|
||||
|
||||
func setNotificationsEnabled(_ enabled: Bool) {
|
||||
defaults.set(enabled, forKey: Key.notificationsEnabled)
|
||||
reload()
|
||||
}
|
||||
|
||||
func setDiagnosticsEnabled(_ enabled: Bool) {
|
||||
defaults.set(enabled, forKey: Key.diagnosticsEnabled)
|
||||
reload()
|
||||
|
||||
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
|
||||
}
|
||||
41
apple/VniDrop/Core/CoreDispatcher.swift
Normal file
41
apple/VniDrop/Core/CoreDispatcher.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
/// Dispatch-queue labels for the core's serial and interrupt lanes.
|
||||
enum QueueLabel {
|
||||
static let core = "com.vnidrop.core"
|
||||
static let interrupt = "com.vnidrop.core.interrupt"
|
||||
}
|
||||
|
||||
/// Two-lane dispatcher for blocking core calls.
|
||||
///
|
||||
/// `run` serializes calls on one queue so the core is driven from a single lane.
|
||||
/// `runInterrupt` uses a *separate* concurrent lane, so an interrupt-style call
|
||||
/// (cancel) can reach the core while a blocking call (`receive`) still occupies
|
||||
/// the serial lane. The core is internally synchronized and explicitly supports
|
||||
/// cancel arriving from another thread mid-receive (see VnidropCore.block_on);
|
||||
/// a single shared queue would deadlock it.
|
||||
final class CoreDispatcher: Sendable {
|
||||
private let serialQueue: DispatchQueue
|
||||
private let interruptQueue: DispatchQueue
|
||||
|
||||
init(label: String = QueueLabel.core, interruptLabel: String = QueueLabel.interrupt) {
|
||||
serialQueue = DispatchQueue(label: label, qos: .userInitiated)
|
||||
interruptQueue = DispatchQueue(label: interruptLabel, qos: .userInitiated, attributes: .concurrent)
|
||||
}
|
||||
|
||||
/// Runs a blocking core call on the serial lane and hops the result back.
|
||||
func run<T: Sendable>(_ block: @escaping @Sendable () throws -> T) async -> Result<T, Error> {
|
||||
await withCheckedContinuation { continuation in
|
||||
serialQueue.async { continuation.resume(returning: Result { try block() }) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Like `run`, but off the serial lane so it can interrupt a blocking call in
|
||||
/// flight there (e.g. cancel a `receive`). Only use for core calls that are
|
||||
/// safe to run concurrently with another core call.
|
||||
func runInterrupt<T: Sendable>(_ block: @escaping @Sendable () throws -> T) async -> Result<T, Error> {
|
||||
await withCheckedContinuation { continuation in
|
||||
interruptQueue.async { continuation.resume(returning: Result { try block() }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,56 @@ struct CoreEventModel: Equatable, Identifiable, Sendable {
|
||||
let timestamp: Int64
|
||||
let scope: String
|
||||
let transferId: UInt64?
|
||||
/// Raw wire values as emitted by the core. Interpret them through the typed
|
||||
/// `eventDirection` / `eventPhase` / `eventKind` accessors below — logic code
|
||||
/// should never compare these strings directly.
|
||||
let direction: String?
|
||||
let phase: String
|
||||
let kind: String
|
||||
let dataJson: String
|
||||
|
||||
var eventDirection: EventDirection? { direction.flatMap(EventDirection.init(rawValue:)) }
|
||||
var eventPhase: EventPhase? { EventPhase(rawValue: phase) }
|
||||
var eventKind: EventKind? { EventKind(rawValue: kind) }
|
||||
}
|
||||
|
||||
/// Direction of a core event, matching the wire strings the core emits.
|
||||
enum EventDirection: String, Equatable, Sendable {
|
||||
case send
|
||||
case receive
|
||||
}
|
||||
|
||||
/// Phase of a core progress event (the `phase` wire field).
|
||||
enum EventPhase: String, Equatable, Sendable {
|
||||
case importing = "import"
|
||||
case ticket
|
||||
case access
|
||||
case transfer
|
||||
case download
|
||||
case export
|
||||
case lifecycle
|
||||
case network
|
||||
case handshake
|
||||
case error
|
||||
}
|
||||
|
||||
/// Kind of a core progress event (the `kind` wire field).
|
||||
enum EventKind: String, Equatable, Sendable {
|
||||
case started
|
||||
case copyProgress = "copy-progress"
|
||||
case copyDone = "copy-done"
|
||||
case outboardProgress = "outboard-progress"
|
||||
case done
|
||||
case created
|
||||
case progress
|
||||
case completed
|
||||
case aborted
|
||||
case failed
|
||||
case connecting
|
||||
case connected
|
||||
case foundCollection = "found-collection"
|
||||
case cancelled
|
||||
case shareStopped = "share-stopped"
|
||||
}
|
||||
|
||||
enum ShareAccessPolicy: Equatable, Sendable {
|
||||
@@ -88,6 +134,7 @@ enum ReceiverDeliveryStatus: Equatable, Sendable {
|
||||
case refused
|
||||
case expired
|
||||
case completed
|
||||
case failed
|
||||
case unknown
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,9 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
||||
// access the handle from the main actor. The underlying core is internally
|
||||
// synchronized, and `nonisolated(unsafe)` documents that crossing for Swift 6.
|
||||
private nonisolated(unsafe) var core: VnidropCore?
|
||||
private let queue = DispatchQueue(label: "com.vnidrop.core", qos: .userInitiated)
|
||||
// Core calls run through `dispatcher` (see runCore/runInterrupt); the factory
|
||||
// and transition flag drive relay-aware (re)initialization.
|
||||
private let dispatcher = CoreDispatcher()
|
||||
private let coreFactory: any CoreBindingFactory
|
||||
private var isNetworkTransitionInProgress = false
|
||||
private lazy var sink = RepositoryEventSink { [weak self] event in
|
||||
@@ -154,7 +156,7 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
||||
return .failure(CoreNetworkLifecycleError.transitionInProgress)
|
||||
}
|
||||
guard !sources.isEmpty else {
|
||||
return .failure(InvitationError.message("Select at least one file to share"))
|
||||
return .failure(InvitationError.shareEmpty)
|
||||
}
|
||||
return await runCore {
|
||||
let result = try self.requireCore().shareFiles(
|
||||
@@ -222,7 +224,9 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
||||
// MARK: - Lifecycle actions
|
||||
|
||||
func cancel(transferId: UInt64) async -> Result<Void, Error> {
|
||||
await runCore {
|
||||
// Off the serial `queue`: a receive in flight is blocking it, and the
|
||||
// cancel signal must reach the core to unblock that receive.
|
||||
await runInterrupt {
|
||||
try self.requireCore().cancelTransfer(transferId: transferId)
|
||||
}.map { self.refreshSnapshot() }
|
||||
}
|
||||
@@ -349,24 +353,21 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
||||
|
||||
private nonisolated func requireCore() throws -> VnidropCore {
|
||||
guard let core = self.core else {
|
||||
throw InvitationError.message("Initialize the core first.")
|
||||
throw InvitationError.coreNotInitialized
|
||||
}
|
||||
return core
|
||||
}
|
||||
|
||||
/// Runs a blocking core call off the main actor and hops the result back.
|
||||
private nonisolated func runCore<T: Sendable>(_ block: @escaping @Sendable () throws -> T) async -> Result<T, Error> {
|
||||
await withCheckedContinuation { continuation in
|
||||
queue.async {
|
||||
let result: Result<T, Error>
|
||||
do {
|
||||
result = .success(try block())
|
||||
} catch {
|
||||
result = .failure(error)
|
||||
}
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
}
|
||||
await dispatcher.run(block)
|
||||
}
|
||||
|
||||
/// Like `runCore`, but off the serial lane so it can interrupt a blocking
|
||||
/// call in flight there (e.g. cancel a `receive`). Only use for core calls
|
||||
/// that are safe to run concurrently with another core call.
|
||||
private nonisolated func runInterrupt<T: Sendable>(_ block: @escaping @Sendable () throws -> T) async -> Result<T, Error> {
|
||||
await dispatcher.runInterrupt(block)
|
||||
}
|
||||
|
||||
private nonisolated static func nextTransferId() -> UInt64 {
|
||||
@@ -401,14 +402,15 @@ private extension CoreEvent {
|
||||
}
|
||||
}
|
||||
|
||||
private let refreshPhases: Set<String> = ["lifecycle", "error", "ticket", "import", "download", "export", "handshake"]
|
||||
private let refreshKinds: Set<String> = [
|
||||
"started", "done", "created", "failed", "cancelled", "share-stopped", "found-collection", "connected",
|
||||
private let refreshPhases: Set<EventPhase> = [.lifecycle, .error, .ticket, .importing, .download, .export, .handshake]
|
||||
private let refreshKinds: Set<EventKind> = [
|
||||
.started, .done, .created, .failed, .cancelled, .shareStopped, .foundCollection, .connected,
|
||||
]
|
||||
|
||||
private extension CoreEventModel {
|
||||
var shouldRefreshTransfers: Bool {
|
||||
refreshPhases.contains(phase) && refreshKinds.contains(kind)
|
||||
guard let eventPhase, let eventKind else { return false }
|
||||
return refreshPhases.contains(eventPhase) && refreshKinds.contains(eventKind)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,6 +514,7 @@ private extension ReceiverRequest {
|
||||
case "refused": return .refused
|
||||
case "expired": return .expired
|
||||
case "completed": return .completed
|
||||
case "failed": return .failed
|
||||
default: return .unknown
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,23 +23,42 @@ final class ExternalInvitationController: ObservableObject {
|
||||
}
|
||||
|
||||
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 {
|
||||
case empty
|
||||
case tooLarge
|
||||
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? {
|
||||
switch self {
|
||||
case .empty: return "The invitation is empty"
|
||||
case .tooLarge: return "The invitation is too large"
|
||||
case .invalidEncoding: return "The invitation is not valid text"
|
||||
case .message(let m): return m
|
||||
}
|
||||
if case .raw(let reason) = self { return reason }
|
||||
return String(describing: self)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ struct PickedShareFile: Equatable, Identifiable, Sendable {
|
||||
var isTemporaryCopy: Bool = false
|
||||
/// When true, `value` is a directory (path or security-scoped folder URL).
|
||||
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 }
|
||||
}
|
||||
@@ -48,7 +52,7 @@ extension FileSystemService {
|
||||
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
|
||||
|
||||
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 {}
|
||||
|
||||
@@ -16,12 +16,53 @@ struct LocalNotification {
|
||||
let body: String
|
||||
}
|
||||
|
||||
/// Presents notifications even while the app is active. Without a delegate the
|
||||
/// system drops the banner when the app is frontmost — very visible on macOS,
|
||||
/// where the app window is usually open when a transfer completes.
|
||||
private final class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate {
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification
|
||||
) async -> UNNotificationPresentationOptions {
|
||||
[.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`.
|
||||
@MainActor
|
||||
final class LocalNotificationService: ObservableObject {
|
||||
@Published private(set) var permission: NotificationPermission = .notDetermined
|
||||
|
||||
private let center = UNUserNotificationCenter.current()
|
||||
private let presenter = NotificationPresenter()
|
||||
|
||||
init() {
|
||||
center.delegate = presenter
|
||||
// Seed the permission immediately so gating (approval/lifecycle
|
||||
// notifications) never races a not-yet-refreshed `.notDetermined`.
|
||||
Task { _ = await refreshPermission() }
|
||||
}
|
||||
|
||||
func refreshPermission() async -> NotificationPermission {
|
||||
let settings = await center.notificationSettings()
|
||||
@@ -75,11 +116,6 @@ final class LocalNotificationService: ObservableObject {
|
||||
center.removeDeliveredNotifications(withIdentifiers: [id])
|
||||
}
|
||||
|
||||
func cancelAll() {
|
||||
center.removeAllPendingNotificationRequests()
|
||||
center.removeAllDeliveredNotifications()
|
||||
}
|
||||
|
||||
private static func map(_ status: UNAuthorizationStatus) -> NotificationPermission {
|
||||
switch status {
|
||||
case .authorized, .provisional, .ephemeral: return .granted
|
||||
|
||||
@@ -18,9 +18,9 @@ func windowClassFor(width: Double) -> WindowClass {
|
||||
/// resolved at the view layer.
|
||||
struct TransferProgress: Equatable {
|
||||
let transferId: UInt64?
|
||||
let phase: String
|
||||
let kind: String
|
||||
let labelKey: String
|
||||
let phase: EventPhase
|
||||
let kind: EventKind
|
||||
let labelKey: String.LocalizationValue
|
||||
let progress: Double?
|
||||
var detail: String? = nil
|
||||
/// Pre-resolved label that overrides `labelKey` when set (e.g. "Sending to 2",
|
||||
@@ -28,44 +28,31 @@ struct TransferProgress: Equatable {
|
||||
var label: String? = nil
|
||||
}
|
||||
|
||||
func statusLabelKey(_ status: TransferStatus) -> String {
|
||||
func statusLabelKey(_ status: TransferStatus) -> String.LocalizationValue {
|
||||
switch status {
|
||||
case .importing: return "status_preparing"
|
||||
case .sharing: return "status_available"
|
||||
case .receiving: return "status_receiving"
|
||||
case .done: return "status_completed"
|
||||
case .cancelled: return "status_cancelled"
|
||||
case .stopped: return "status_stopped"
|
||||
case .failed: return "status_failed"
|
||||
case .importing: return L10n.Status.preparing
|
||||
case .sharing: return L10n.Status.available
|
||||
case .receiving: return L10n.Status.receiving
|
||||
case .done: return L10n.Status.completed
|
||||
case .cancelled: return L10n.Status.cancelled
|
||||
case .stopped: return L10n.Status.stopped
|
||||
case .failed: return L10n.Status.failed
|
||||
}
|
||||
}
|
||||
|
||||
private let progressPhases: Set<String> = [
|
||||
"import", "ticket", "access", "transfer", "download", "export",
|
||||
"lifecycle", "network", "handshake", "error",
|
||||
]
|
||||
|
||||
private let progressKinds: Set<String> = [
|
||||
"started", "copy-progress", "copy-done", "outboard-progress", "done",
|
||||
"created", "progress", "completed", "aborted", "failed",
|
||||
"connecting", "connected", "found-collection",
|
||||
"cancelled", "share-stopped",
|
||||
]
|
||||
|
||||
/// Latest progress snapshot for a transfer. Events are newest-first.
|
||||
/// Latest progress snapshot for a transfer. Events are newest-first. Only events
|
||||
/// whose `phase` and `kind` map to known cases participate.
|
||||
func progressForTransfer(events: [CoreEventModel], transferId: UInt64) -> TransferProgress? {
|
||||
let relevant = events.filter { event in
|
||||
event.transferId == transferId
|
||||
&& progressPhases.contains(event.phase)
|
||||
&& progressKinds.contains(event.kind)
|
||||
event.transferId == transferId && event.eventPhase != nil && event.eventKind != nil
|
||||
}
|
||||
guard let latest = relevant.first else { return nil }
|
||||
guard let latest = relevant.first, let phase = latest.eventPhase, let kind = latest.eventKind else { return nil }
|
||||
let sizeHint = findKnownSize(events: events, transferId: transferId)
|
||||
return TransferProgress(
|
||||
transferId: transferId,
|
||||
phase: latest.phase,
|
||||
kind: latest.kind,
|
||||
labelKey: humanProgressLabel(latest),
|
||||
phase: phase,
|
||||
kind: kind,
|
||||
labelKey: humanProgressLabel(phase: phase, kind: kind),
|
||||
progress: parseProgress(latest.dataJson, sizeHint: sizeHint),
|
||||
detail: progressDetail(latest)
|
||||
)
|
||||
@@ -80,33 +67,34 @@ func progressForReceiver(
|
||||
) -> TransferProgress? {
|
||||
if remoteEndpointId.isEmpty { return nil }
|
||||
let connectionIds = connectionIdsForEndpoint(events: events, remoteEndpointId: remoteEndpointId)
|
||||
let receiverKinds: Set<EventKind> = [.started, .progress, .completed, .aborted]
|
||||
let transferEvents = events.filter { event in
|
||||
event.transferId == transferId
|
||||
&& event.direction == "send"
|
||||
&& event.phase == "transfer"
|
||||
&& ["started", "progress", "completed", "aborted"].contains(event.kind)
|
||||
&& event.eventDirection == .send
|
||||
&& event.eventPhase == .transfer
|
||||
&& (event.eventKind.map(receiverKinds.contains) ?? false)
|
||||
&& eventBelongsToReceiver(event, remoteEndpointId: remoteEndpointId, connectionIds: connectionIds)
|
||||
}
|
||||
if transferEvents.isEmpty { return nil }
|
||||
|
||||
let latest = transferEvents[0]
|
||||
if latest.kind == "aborted" {
|
||||
guard let latest = transferEvents.first, let latestKind = latest.eventKind else { return nil }
|
||||
if latestKind == .aborted {
|
||||
return TransferProgress(
|
||||
transferId: transferId, phase: "transfer", kind: "aborted",
|
||||
labelKey: "progress_interrupted", progress: nil, detail: nil
|
||||
transferId: transferId, phase: .transfer, kind: .aborted,
|
||||
labelKey: L10n.Progress.interrupted, progress: nil, detail: nil
|
||||
)
|
||||
}
|
||||
// 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(
|
||||
transferId: transferId, phase: .transfer, kind: .completed,
|
||||
labelKey: L10n.Progress.completed, progress: 1, detail: nil
|
||||
)
|
||||
}
|
||||
let progress = aggregateReceiverProgress(events: transferEvents, totalSizeHint: totalSizeHint)
|
||||
if latest.kind == "completed" && (progress.map { $0 >= 0.999 } ?? true) {
|
||||
return TransferProgress(
|
||||
transferId: transferId, phase: "transfer", kind: "completed",
|
||||
labelKey: "progress_completed", progress: 1, detail: nil
|
||||
)
|
||||
}
|
||||
|
||||
return TransferProgress(
|
||||
transferId: transferId, phase: "transfer", kind: latest.kind,
|
||||
labelKey: "progress_sending", progress: progress, detail: progressDetail(latest)
|
||||
transferId: transferId, phase: .transfer, kind: latestKind,
|
||||
labelKey: L10n.Progress.sending, progress: progress, detail: progressDetail(latest)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -127,26 +115,26 @@ func formatBytes(_ size: UInt64) -> String {
|
||||
|
||||
// MARK: - Internals (ported literally from AppUiModels.kt)
|
||||
|
||||
private func humanProgressLabel(_ event: CoreEventModel) -> String {
|
||||
switch (event.phase, event.kind) {
|
||||
case ("import", "copy-progress"), ("import", "outboard-progress"), ("import", "started"):
|
||||
return "progress_preparing"
|
||||
case ("import", "done"): return "progress_ready"
|
||||
case ("ticket", "created"): return "progress_share_ready"
|
||||
case ("network", "connecting"): return "progress_connecting"
|
||||
case ("network", "connected"): return "progress_connected"
|
||||
case ("download", "found-collection"): return "progress_getting_ready"
|
||||
case ("download", "progress"): return "progress_downloading"
|
||||
case ("export", "progress"): return "progress_saving"
|
||||
case ("transfer", "progress"): return "progress_sending"
|
||||
case ("transfer", "started"): return "progress_connected"
|
||||
case ("transfer", "completed"): return "progress_completed"
|
||||
case ("lifecycle", "done"): return "progress_completed"
|
||||
case ("lifecycle", "cancelled"): return "progress_cancelled"
|
||||
private func humanProgressLabel(phase: EventPhase, kind: EventKind) -> String.LocalizationValue {
|
||||
switch (phase, kind) {
|
||||
case (.importing, .copyProgress), (.importing, .outboardProgress), (.importing, .started):
|
||||
return L10n.Progress.preparing
|
||||
case (.importing, .done): return L10n.Progress.ready
|
||||
case (.ticket, .created): return L10n.Progress.shareReady
|
||||
case (.network, .connecting): return L10n.Progress.connecting
|
||||
case (.network, .connected): return L10n.Progress.connected
|
||||
case (.download, .foundCollection): return L10n.Progress.gettingReady
|
||||
case (.download, .progress): return L10n.Progress.downloading
|
||||
case (.export, .progress): return L10n.Progress.saving
|
||||
case (.transfer, .progress): return L10n.Progress.sending
|
||||
case (.transfer, .started): return L10n.Progress.connected
|
||||
case (.transfer, .completed): return L10n.Progress.completed
|
||||
case (.lifecycle, .done): return L10n.Progress.completed
|
||||
case (.lifecycle, .cancelled): return L10n.Progress.cancelled
|
||||
default:
|
||||
if event.phase == "handshake" { return "progress_requesting_access" }
|
||||
if event.kind == "failed" { return "progress_failed" }
|
||||
return "progress_working"
|
||||
if phase == .handshake { return L10n.Progress.requestingAccess }
|
||||
if kind == .failed { return L10n.Progress.failed }
|
||||
return L10n.Progress.working
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,14 +205,14 @@ private func aggregateReceiverProgress(events: [CoreEventModel], totalSizeHint:
|
||||
order.append(requestKey)
|
||||
}
|
||||
if let size, size > 0 { state.size = size }
|
||||
switch event.kind {
|
||||
case "progress", "started":
|
||||
switch event.eventKind {
|
||||
case .progress, .started:
|
||||
if let endOffset { state.offset = max(state.offset, endOffset) }
|
||||
state.aborted = false
|
||||
case "completed":
|
||||
case .completed:
|
||||
state.completed = true
|
||||
if let s = state.size { state.offset = s }
|
||||
case "aborted":
|
||||
case .aborted:
|
||||
state.aborted = true
|
||||
default:
|
||||
break
|
||||
|
||||
@@ -27,7 +27,6 @@ final class ApprovalCoordinator: ObservableObject {
|
||||
@Published private(set) var state = ApprovalState()
|
||||
|
||||
private let repository: CoreGateway
|
||||
private let preferences: AppPreferencesRepository
|
||||
private let notifications: LocalNotificationService
|
||||
private let visibility: AppVisibility
|
||||
private let messages: UiMessageController
|
||||
@@ -37,13 +36,11 @@ final class ApprovalCoordinator: ObservableObject {
|
||||
|
||||
init(
|
||||
repository: CoreGateway,
|
||||
preferences: AppPreferencesRepository,
|
||||
notifications: LocalNotificationService,
|
||||
visibility: AppVisibility,
|
||||
messages: UiMessageController
|
||||
) {
|
||||
self.repository = repository
|
||||
self.preferences = preferences
|
||||
self.notifications = notifications
|
||||
self.visibility = visibility
|
||||
self.messages = messages
|
||||
@@ -68,17 +65,15 @@ final class ApprovalCoordinator: ObservableObject {
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Recompute notifications when any input changes.
|
||||
Publishers.CombineLatest4(
|
||||
preferences.$preferences,
|
||||
Publishers.CombineLatest3(
|
||||
visibility.$isForeground,
|
||||
$state,
|
||||
notifications.$permission
|
||||
)
|
||||
.sink { [weak self] preferences, foreground, approvalState, permission in
|
||||
.sink { [weak self] foreground, approvalState, permission in
|
||||
guard let self else { return }
|
||||
Task {
|
||||
await self.synchronizeNotifications(
|
||||
enabled: preferences.notificationsEnabled,
|
||||
foreground: foreground,
|
||||
pending: approvalState.pending,
|
||||
permission: permission
|
||||
@@ -137,30 +132,41 @@ final class ApprovalCoordinator: ObservableObject {
|
||||
}
|
||||
|
||||
private func synchronizeNotifications(
|
||||
enabled: Bool,
|
||||
foreground: Bool,
|
||||
pending: [PendingApproval],
|
||||
permission: NotificationPermission
|
||||
) async {
|
||||
if foreground || !enabled || permission != .granted {
|
||||
notifications.cancelAll()
|
||||
// iOS suppresses notifications while the user is in the app (the modal shows
|
||||
// instead); macOS presents them even when active (the app window is usually
|
||||
// open), relying on the presenter delegate.
|
||||
#if os(iOS)
|
||||
let suppressed = foreground || permission != .granted
|
||||
#else
|
||||
let suppressed = permission != .granted
|
||||
#endif
|
||||
if suppressed {
|
||||
// Cancel only our own approval notifications — other coordinators
|
||||
// (e.g. transfer-lifecycle) manage their own and must not be wiped.
|
||||
for id in publishedNotificationIds { notifications.cancel(id: Self.notificationId(id)) }
|
||||
return
|
||||
}
|
||||
for request in pending where !publishedNotificationIds.contains(request.id) {
|
||||
// Reserve the id *before* awaiting: the CombineLatest can fire several
|
||||
// times near-simultaneously, and without this each pass re-adds the same
|
||||
// notification identifier. macOS coalesces a repeated add of an in-flight
|
||||
// id into a silent update and shows no banner.
|
||||
publishedNotificationIds.insert(request.id)
|
||||
let receiver = request.receiverName
|
||||
?? request.receiverDeviceName
|
||||
?? String(localized: "approval_nearby_device")
|
||||
let title = String(localized: "approval_connection_request")
|
||||
let body = String(
|
||||
format: String(localized: "approval_request_body"),
|
||||
receiver, request.transferName
|
||||
)
|
||||
?? String(localized: L10n.Approval.nearbyDevice)
|
||||
let title = String(localized: L10n.Approval.connectionRequest)
|
||||
let body = L10n.Approval.requestBody(receiver: receiver, transferName: request.transferName)
|
||||
let result = await notifications.publish(
|
||||
LocalNotification(id: Self.notificationId(request.id), title: title, body: body)
|
||||
)
|
||||
switch result {
|
||||
case .success: publishedNotificationIds.insert(request.id)
|
||||
case .failure(let error): messages.error(error)
|
||||
if case .failure(let error) = result {
|
||||
publishedNotificationIds.remove(request.id)
|
||||
messages.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
|
||||
/// Non-dismissable receiver-approval modal, presented as a native sheet that can't
|
||||
/// be swiped away. The endpoint id is the trusted identity; display names are
|
||||
/// peer-provided.
|
||||
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 onAccept: (String) -> Void
|
||||
let onRefuse: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.sheet(isPresented: .constant(state.current != nil)) {
|
||||
.sheet(isPresented: $isPresented) {
|
||||
if let request = state.current {
|
||||
ApprovalSheet(state: state, request: request, onAccept: onAccept, onRefuse: onRefuse)
|
||||
.interactiveDismissDisabled(true)
|
||||
@@ -38,32 +43,32 @@ private struct ApprovalSheet: View {
|
||||
|
||||
var body: some View {
|
||||
let busy = state.respondingIds.contains(request.id)
|
||||
let receiver = request.receiverName ?? request.receiverDeviceName ?? String(localized: "approval_nearby_device")
|
||||
let receiver = request.receiverName ?? request.receiverDeviceName ?? String(localized: L10n.Approval.nearbyDevice)
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "checkmark.shield.fill")
|
||||
Image(systemSymbol: .checkmarkShieldFill)
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(.tint)
|
||||
.padding(.top, 12)
|
||||
Text(LocalizedStringKey("approval_connection_request"))
|
||||
Text(String(localized: L10n.Approval.connectionRequest))
|
||||
.font(.title2).fontWeight(.semibold)
|
||||
Text(String(format: String(localized: "approval_request_body"), receiver, request.transferName))
|
||||
Text(L10n.Approval.requestBody(receiver: receiver, transferName: request.transferName))
|
||||
.multilineTextAlignment(.center)
|
||||
Text(String(format: String(localized: "approval_endpoint_id"), request.remoteEndpointId))
|
||||
Text(L10n.Approval.endpointId(deviceId: request.remoteEndpointId))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
if state.pending.count > 1 {
|
||||
Text(String(format: String(localized: "approval_pending_count"), state.pending.count))
|
||||
Text(L10n.Approval.pendingCount(count: state.pending.count))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if busy { ProgressView() }
|
||||
VStack(spacing: 10) {
|
||||
Button(action: { onAccept(request.id) }) {
|
||||
Text(LocalizedStringKey("button_approve")).frame(maxWidth: .infinity)
|
||||
Text(String(localized: L10n.Button.approve)).frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent).controlSize(.large).disabled(busy)
|
||||
Button(role: .destructive, action: { onRefuse(request.id) }) {
|
||||
Text(LocalizedStringKey("button_refuse")).frame(maxWidth: .infinity)
|
||||
Text(String(localized: L10n.Button.refuse)).frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered).controlSize(.large).disabled(busy)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// A transfer-lifecycle moment worth a local notification.
|
||||
enum TransferNotificationKind: Equatable {
|
||||
case sendFailed // A share you own failed.
|
||||
case receiveCompleted // An incoming transfer finished downloading.
|
||||
case receiveFailed // An incoming transfer failed.
|
||||
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`
|
||||
/// is the raw name (may be nil); the coordinator localizes and applies fallbacks.
|
||||
struct PlannedNotification: Equatable {
|
||||
let id: String
|
||||
let kind: TransferNotificationKind
|
||||
let transferName: String?
|
||||
let receiver: String?
|
||||
}
|
||||
|
||||
/// Pure: transfer-status notifications for this snapshot, excluding already-published
|
||||
/// ids. A terminal transfer yields at most one notification, keyed by (kind, id).
|
||||
func plannedTransferNotifications(_ transfers: [Transfer], published: Set<String>) -> [PlannedNotification] {
|
||||
transfers.compactMap { transfer in
|
||||
let kind: TransferNotificationKind
|
||||
switch (transfer.direction, transfer.status) {
|
||||
case (.send, .failed): kind = .sendFailed
|
||||
case (.receive, .done): kind = .receiveCompleted
|
||||
case (.receive, .failed): kind = .receiveFailed
|
||||
default: return nil
|
||||
}
|
||||
let id = transferNotificationId(kind, transferId: transfer.transferId)
|
||||
guard !published.contains(id) else { return nil }
|
||||
return PlannedNotification(id: id, kind: kind, transferName: transfer.transferName, receiver: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure: one notification per receiver that has finished downloading a shared
|
||||
/// transfer, excluding already-published ids.
|
||||
func plannedReceiverNotifications(_ requests: [ReceiverRequestModel], published: Set<String>) -> [PlannedNotification] {
|
||||
requests.compactMap { request in
|
||||
let kind: TransferNotificationKind
|
||||
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 }
|
||||
return PlannedNotification(
|
||||
id: id, kind: kind,
|
||||
transferName: request.transferName,
|
||||
receiver: request.receiverName ?? request.receiverDeviceName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func transferNotificationId(_ kind: TransferNotificationKind, transferId: UInt64) -> String {
|
||||
switch kind {
|
||||
case .sendFailed: return "send-failed-\(transferId)"
|
||||
case .receiveCompleted: return "receive-completed-\(transferId)"
|
||||
case .receiveFailed: return "receive-failed-\(transferId)"
|
||||
case .receiverCompleted: return "receiver-completed-\(transferId)"
|
||||
case .receiverFailed: return "receiver-failed-\(transferId)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Fires local notifications for transfer-lifecycle moments (a receive finishing
|
||||
/// or failing, a share failing, a receiver completing), so a user who left the
|
||||
/// app can see the outcome. Approval prompts are handled by `ApprovalCoordinator`.
|
||||
///
|
||||
/// Gated on the OS notification permission (and, on iOS, on being backgrounded).
|
||||
/// Each moment is terminal, so it is marked seen the first time it is observed and
|
||||
/// never re-published. The first state snapshot — which includes existing history
|
||||
/// such as past receives — only primes those ids as seen, so only new transitions
|
||||
/// notify.
|
||||
@MainActor
|
||||
final class TransferNotificationCoordinator: ObservableObject {
|
||||
private let repository: CoreGateway
|
||||
private let notifications: LocalNotificationService
|
||||
private let visibility: AppVisibility
|
||||
private let messages: UiMessageController
|
||||
|
||||
private var published = Set<String>()
|
||||
private var primedTransfers = false
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
repository: CoreGateway,
|
||||
notifications: LocalNotificationService,
|
||||
visibility: AppVisibility,
|
||||
messages: UiMessageController
|
||||
) {
|
||||
self.repository = repository
|
||||
self.notifications = notifications
|
||||
self.visibility = visibility
|
||||
self.messages = messages
|
||||
|
||||
repository.statePublisher
|
||||
.sink { [weak self] core in
|
||||
guard let self, core.isInitialized else { return }
|
||||
Task { await self.syncTransfers(core.transfers) }
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
repository.signals
|
||||
.sink { [weak self] signal in
|
||||
guard let self else { return }
|
||||
switch signal {
|
||||
case .receiverHistoryChanged(let transferId), .transfersChanged(let transferId):
|
||||
Task { await self.syncReceivers(transferId: transferId) }
|
||||
case .approvalChanged:
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
/// iOS suppresses notifications while the user is in the app (the convention);
|
||||
/// macOS presents them even when active (also the convention — the app window
|
||||
/// is usually open), relying on the presenter delegate to show the banner.
|
||||
private var canPublish: Bool {
|
||||
guard notifications.permission == .granted else { return false }
|
||||
#if os(iOS)
|
||||
return !visibility.isForeground
|
||||
#else
|
||||
return true
|
||||
#endif
|
||||
}
|
||||
|
||||
private func syncTransfers(_ transfers: [Transfer]) async {
|
||||
let planned = plannedTransferNotifications(transfers, published: published)
|
||||
guard primedTransfers else {
|
||||
// The first snapshot includes existing history (e.g. past receives).
|
||||
// Mark those terminal transfers seen without notifying, so only new
|
||||
// transitions notify.
|
||||
primedTransfers = true
|
||||
for plan in planned { published.insert(plan.id) }
|
||||
return
|
||||
}
|
||||
for plan in planned { await deliver(plan) }
|
||||
}
|
||||
|
||||
private func syncReceivers(transferId: UInt64) async {
|
||||
let result = await repository.receiverRequests(transferId: transferId)
|
||||
switch result {
|
||||
case .success(let requests):
|
||||
for plan in plannedReceiverNotifications(requests, published: published) {
|
||||
await deliver(plan)
|
||||
}
|
||||
case .failure(let error):
|
||||
messages.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark seen unconditionally (a terminal moment notifies at most once), then
|
||||
/// publish only when the gate allows.
|
||||
private func deliver(_ plan: PlannedNotification) async {
|
||||
published.insert(plan.id)
|
||||
guard canPublish else { return }
|
||||
let name = plan.transferName ?? String(localized: L10n.Receive.unknownTransfer)
|
||||
let notification: LocalNotification
|
||||
switch plan.kind {
|
||||
case .sendFailed:
|
||||
notification = LocalNotification(
|
||||
id: plan.id,
|
||||
title: String(localized: L10n.Notifications.sendFailedTitle),
|
||||
body: L10n.Notifications.sendFailedBody(transferName: name))
|
||||
case .receiveCompleted:
|
||||
notification = LocalNotification(
|
||||
id: plan.id,
|
||||
title: String(localized: L10n.Notifications.receiveCompletedTitle),
|
||||
body: L10n.Notifications.receiveCompletedBody(transferName: name))
|
||||
case .receiveFailed:
|
||||
notification = LocalNotification(
|
||||
id: plan.id,
|
||||
title: String(localized: L10n.Notifications.receiveFailedTitle),
|
||||
body: L10n.Notifications.receiveFailedBody(transferName: name))
|
||||
case .receiverCompleted:
|
||||
let receiver = plan.receiver ?? String(localized: L10n.Approval.nearbyDevice)
|
||||
notification = LocalNotification(
|
||||
id: plan.id,
|
||||
title: String(localized: L10n.Notifications.receiverCompletedTitle),
|
||||
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) {
|
||||
messages.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import SFSafeSymbols
|
||||
import SwiftUI
|
||||
|
||||
enum ReceiveMethodAvailability { case available, unavailable, hidden }
|
||||
|
||||
/// Invitation acquisition actions shared by the native Apple feature models.
|
||||
/// Invitation acquisition actions, ported from `ReceiveInvitationActions` (iosMain).
|
||||
@MainActor
|
||||
protocol ReceiveInvitationActions: AnyObject {
|
||||
var fileAvailability: ReceiveMethodAvailability { get }
|
||||
@@ -23,25 +24,25 @@ struct ReceiveMethodPanel: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(LocalizedStringKey("receive_choose_method_title")).font(VniType.titleLarge)
|
||||
Text(LocalizedStringKey("receive_choose_method_body")).foregroundStyle(colors.foregroundLighter)
|
||||
Text(String(localized: L10n.Receive.chooseMethodTitle)).font(VniType.titleLarge)
|
||||
Text(String(localized: L10n.Receive.chooseMethodBody)).foregroundStyle(colors.foregroundLighter)
|
||||
|
||||
MethodRow(
|
||||
icon: "doc", titleKey: "receive_method_file", descKey: "receive_method_file_description",
|
||||
icon: .doc, titleKey: L10n.Receive.methodFile, descKey: L10n.Receive.methodFileDescription,
|
||||
availability: actions.fileAvailability
|
||||
) { actions.pickInvitation { model.onInvitationResult(.invitationFile, $0) } }
|
||||
|
||||
if actions.qrAvailability != .hidden {
|
||||
MethodRow(
|
||||
icon: "qrcode.viewfinder", titleKey: "receive_method_scan", descKey: "receive_method_scan_description",
|
||||
icon: .qrcodeViewfinder, titleKey: L10n.Receive.methodScan, descKey: L10n.Receive.methodScanDescription,
|
||||
availability: actions.qrAvailability
|
||||
) { actions.scanQrCode { model.onInvitationResult(.qrCode, $0) } }
|
||||
}
|
||||
if actions.nfcAvailability != .hidden {
|
||||
MethodRow(
|
||||
icon: "wave.3.right",
|
||||
titleOverride: model.state.isWaitingForNfc ? String(localized: "receive_nfc_waiting") : nil,
|
||||
titleKey: "receive_method_nfc", descKey: "receive_method_nfc_description",
|
||||
icon: .wave3Right,
|
||||
titleOverride: model.state.isWaitingForNfc ? String(localized: L10n.Receive.nfcWaiting) : nil,
|
||||
titleKey: L10n.Receive.methodNfc, descKey: L10n.Receive.methodNfcDescription,
|
||||
availability: model.state.isWaitingForNfc ? .unavailable : actions.nfcAvailability
|
||||
) {
|
||||
model.setWaitingForNfc(true)
|
||||
@@ -56,10 +57,10 @@ struct ReceiveMethodPanel: View {
|
||||
|
||||
private struct MethodRow: View {
|
||||
@Environment(\.vniColors) private var colors
|
||||
let icon: String
|
||||
let icon: SFSymbol
|
||||
var titleOverride: String? = nil
|
||||
let titleKey: String
|
||||
let descKey: String
|
||||
let titleKey: String.LocalizationValue
|
||||
let descKey: String.LocalizationValue
|
||||
let availability: ReceiveMethodAvailability
|
||||
let onTap: () -> Void
|
||||
|
||||
@@ -67,20 +68,20 @@ private struct MethodRow: View {
|
||||
let enabled = availability == .available
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: icon).font(.system(size: 22))
|
||||
Image(systemSymbol: icon).font(.system(size: 22))
|
||||
.foregroundStyle(enabled ? colors.brandLink : colors.foregroundLighter)
|
||||
.frame(width: 24)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
if let titleOverride {
|
||||
Text(titleOverride).font(VniType.bodyLarge)
|
||||
} else {
|
||||
Text(LocalizedStringKey(titleKey)).font(VniType.bodyLarge)
|
||||
Text(String(localized: titleKey)).font(VniType.bodyLarge)
|
||||
}
|
||||
Text(LocalizedStringKey(descKey)).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||
Text(String(localized: descKey)).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||
}
|
||||
Spacer()
|
||||
if availability == .unavailable {
|
||||
Text(LocalizedStringKey("value_unavailable")).font(VniType.labelSmall).foregroundStyle(colors.foregroundLighter)
|
||||
Text(String(localized: L10n.Value.unavailable)).font(VniType.labelSmall).foregroundStyle(colors.foregroundLighter)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
@@ -102,7 +103,7 @@ struct InvitationReviewPanel: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text(LocalizedStringKey("receive_review_title")).font(VniType.titleLarge)
|
||||
Text(String(localized: L10n.Receive.reviewTitle)).font(VniType.titleLarge)
|
||||
if state.isInspecting {
|
||||
ProgressView().frame(maxWidth: .infinity).padding(40)
|
||||
}
|
||||
@@ -110,28 +111,31 @@ struct InvitationReviewPanel: View {
|
||||
let metadata = inspection.metadata
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(metadata.transferName).font(VniType.bodyLarge).lineLimit(2)
|
||||
Text("\(metadata.fileCount) \(String(localized: "metadata_files").lowercased()) · \(formatBytes(metadata.totalSize))")
|
||||
Text(L10n.Format.separatedTriple(
|
||||
first: "\(metadata.fileCount)",
|
||||
second: String(localized: L10n.Metadata.files).lowercased(),
|
||||
third: formatBytes(metadata.totalSize)))
|
||||
.foregroundStyle(colors.foregroundLighter)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(colors.backgroundSurface200, in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
Field(label: String(localized: "field_receiver_name"),
|
||||
Field(label: String(localized: L10n.Field.receiverName),
|
||||
value: Binding(get: { state.receiverName }, set: { model.setReceiverName($0) }))
|
||||
Text(state.receiveFolder?.displayName ?? String(localized: "value_unavailable"))
|
||||
Text(state.receiveFolder?.displayName ?? String(localized: L10n.Value.unavailable))
|
||||
.font(VniType.bodySmall)
|
||||
.foregroundStyle(state.folderAccessStatus == .writable ? colors.foregroundLight : colors.destructiveDefault)
|
||||
|
||||
if state.isReceiving {
|
||||
let progressId = state.activeReceiveTransferId
|
||||
?? model.coreState.events.first { $0.direction == "receive" && $0.transferId != nil }?.transferId
|
||||
?? model.coreState.events.first { $0.eventDirection == .receive && $0.transferId != nil }?.transferId
|
||||
let progress = progressId.flatMap { progressForTransfer(events: model.coreState.events, transferId: $0) }
|
||||
ProgressRow(labelKey: progress?.labelKey ?? "progress_receiving", progress: progress?.progress, detail: progress?.detail)
|
||||
SecondaryButton(title: String(localized: "button_cancel_receive"), action: model.cancelActiveReceive)
|
||||
ProgressRow(labelKey: progress?.labelKey ?? L10n.Progress.receiving, progress: progress?.progress, detail: progress?.detail)
|
||||
SecondaryButton(title: String(localized: L10n.Button.cancelReceive), action: model.cancelActiveReceive)
|
||||
} else {
|
||||
PrimaryButton(
|
||||
title: String(localized: "button_receive"), action: model.receive,
|
||||
title: String(localized: L10n.Button.receive), action: model.receive,
|
||||
enabled: state.canReceive(coreInitialized: model.coreState.isInitialized)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -121,6 +121,10 @@ final class ReceiveModel: ObservableObject {
|
||||
func confirmHistoryDelete() {
|
||||
guard let target = state.historyDeleteTarget, !state.isDeletingHistory else { return }
|
||||
state.isDeletingHistory = true
|
||||
// Close synchronously: the alert's dismiss binding runs async and no-ops while
|
||||
// `isDeletingHistory`, which would otherwise leave the target set and macOS
|
||||
// re-present it.
|
||||
state.historyDeleteTarget = nil
|
||||
Task {
|
||||
let result: Result<Void, Error>
|
||||
switch target {
|
||||
@@ -133,7 +137,7 @@ final class ReceiveModel: ObservableObject {
|
||||
case .success:
|
||||
state.historyDeleteTarget = nil
|
||||
state.isDeletingHistory = false
|
||||
let key = target == .all ? "receive_history_cleared" : "transfer_deleted"
|
||||
let key = target == .all ? L10n.Receive.historyCleared : L10n.Transfer.deleted
|
||||
messages.tryShow(UiMessage(text: .resource(key), tone: .success))
|
||||
case .failure(let error):
|
||||
state.isDeletingHistory = false
|
||||
@@ -173,9 +177,9 @@ final class ReceiveModel: ObservableObject {
|
||||
resetAcquisition()
|
||||
let canReveal = fileSystemService.canRevealReceiveFolder(folder)
|
||||
messages.tryShow(UiMessage(
|
||||
text: .resource("receive_completed"),
|
||||
text: .resource(L10n.Receive.completed),
|
||||
tone: .success,
|
||||
actionLabel: canReveal ? .resource("button_show_in_files") : nil,
|
||||
actionLabel: canReveal ? .resource(L10n.Button.showInFiles) : nil,
|
||||
onAction: canReveal ? { self.revealReceiveFolder(folder) } : nil
|
||||
))
|
||||
case .failure(let error):
|
||||
@@ -192,8 +196,8 @@ final class ReceiveModel: ObservableObject {
|
||||
messages.tryShow(UiMessage(
|
||||
text: uiText,
|
||||
tone: .error,
|
||||
actionLabel: error.canRetryWithoutChangingInput ? .resource("button_retry") : nil,
|
||||
onAction: error.canRetryWithoutChangingInput ? { self.receive() } : nil
|
||||
actionLabel: .resource(L10n.Button.retry),
|
||||
onAction: { self.receive() }
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -202,7 +206,7 @@ final class ReceiveModel: ObservableObject {
|
||||
func cancelActiveReceive() {
|
||||
let transferId = state.activeReceiveTransferId
|
||||
?? coreState.transfers.first { $0.direction == .receive && $0.status == .receiving }?.transferId
|
||||
?? coreState.events.first { $0.direction == "receive" && $0.transferId != nil }?.transferId
|
||||
?? coreState.events.first { $0.eventDirection == .receive && $0.transferId != nil }?.transferId
|
||||
guard let transferId else { return }
|
||||
Task {
|
||||
let result = await repository.cancel(transferId: transferId)
|
||||
@@ -222,14 +226,14 @@ final class ReceiveModel: ObservableObject {
|
||||
Task {
|
||||
let result = await fileSystemService.revealReceiveFolder(folder)
|
||||
if case .failure = result {
|
||||
messages.show(UiMessage(text: .resource("receive_open_files_failed"), tone: .error))
|
||||
messages.show(UiMessage(text: .resource(L10n.Receive.openFilesFailed), tone: .error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func inspectInvitation(_ method: ReceiveMethod, _ raw: String) {
|
||||
let ticket = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if ticket.isEmpty { return messages.error(.resource("error_invitation_empty")) }
|
||||
if ticket.isEmpty { return messages.error(.resource(L10n.Error.invitationEmpty)) }
|
||||
state.isAcquisitionOpen = true
|
||||
state.ticket = ticket
|
||||
state.method = method
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
|
||||
/// Receive screen, rebuilt on native SwiftUI. A grouped `List` of received
|
||||
/// transfers with swipe-to-delete, and the acquisition flow as a native sheet.
|
||||
@@ -22,17 +23,17 @@ struct ReceiveScreen: View {
|
||||
history
|
||||
}
|
||||
}
|
||||
.navigationTitle(Text(LocalizedStringKey("receive_title")))
|
||||
.navigationTitle(Text(String(localized: L10n.Receive.title)))
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(action: model.openAcquisition) {
|
||||
Label(String(localized: "button_receive_files"), systemImage: "plus")
|
||||
Label(String(localized: L10n.Button.receiveFiles), systemSymbol: .plus)
|
||||
}
|
||||
}
|
||||
if !deletable.isEmpty {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(role: .destructive, action: model.requestClearHistory) {
|
||||
Label(String(localized: "receive_clear_history"), systemImage: "trash")
|
||||
Label(String(localized: L10n.Receive.clearHistory), systemSymbol: .trash)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,11 +51,11 @@ struct ReceiveScreen: View {
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
Text(LocalizedStringKey(clearAllPending ? "receive_clear_history_title" : "receive_delete_history_title")),
|
||||
Text(String(localized: clearAllPending ? L10n.Receive.clearHistoryTitle : L10n.Receive.deleteHistoryTitle)),
|
||||
isPresented: Binding(get: { model.state.historyDeleteTarget != nil }, set: { if !$0 { Task { @MainActor in model.dismissHistoryDelete() } } })
|
||||
) {
|
||||
Button(String(localized: "button_cancel"), role: .cancel, action: model.dismissHistoryDelete)
|
||||
Button(String(localized: clearAllPending ? "receive_clear_history" : "button_delete_transfer"),
|
||||
Button(String(localized: L10n.Button.cancel), role: .cancel, action: model.dismissHistoryDelete)
|
||||
Button(String(localized: clearAllPending ? L10n.Receive.clearHistory : L10n.Button.deleteTransfer),
|
||||
role: .destructive, action: model.confirmHistoryDelete)
|
||||
} message: {
|
||||
historyDeleteMessage
|
||||
@@ -74,27 +75,36 @@ struct ReceiveScreen: View {
|
||||
Button(role: .destructive) {
|
||||
model.requestDeleteHistoryItem(transfer.transferId)
|
||||
} label: {
|
||||
Label(String(localized: "button_delete_transfer"), systemImage: "trash")
|
||||
Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash)
|
||||
}
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
if transfer.status.isTerminalReceiveHistory {
|
||||
Button(role: .destructive) {
|
||||
model.requestDeleteHistoryItem(transfer.transferId)
|
||||
} label: {
|
||||
Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(LocalizedStringKey("receive_history_title"))
|
||||
Text(String(localized: L10n.Receive.historyTitle))
|
||||
} footer: {
|
||||
Text(LocalizedStringKey("receive_new_subtitle"))
|
||||
Text(String(localized: L10n.Receive.newSubtitle))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
ContentUnavailableView {
|
||||
Label(String(localized: "receive_empty_title"), systemImage: "tray.and.arrow.down")
|
||||
Label(String(localized: L10n.Receive.emptyTitle), systemSymbol: .trayAndArrowDown)
|
||||
} description: {
|
||||
Text(LocalizedStringKey("receive_empty_body"))
|
||||
Text(String(localized: L10n.Receive.emptyBody))
|
||||
} actions: {
|
||||
Button(action: model.openAcquisition) {
|
||||
Label(String(localized: "button_receive_files"), systemImage: "plus")
|
||||
Label(String(localized: L10n.Button.receiveFiles), systemSymbol: .plus)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
@@ -107,10 +117,10 @@ struct ReceiveScreen: View {
|
||||
private var historyDeleteMessage: some View {
|
||||
if let target = model.state.historyDeleteTarget {
|
||||
if target == .all {
|
||||
Text(LocalizedStringKey("receive_clear_history_description"))
|
||||
Text(String(localized: L10n.Receive.clearHistoryDescription))
|
||||
} else {
|
||||
Text(String(format: String(localized: "receive_delete_history_description"),
|
||||
transferName(for: target) ?? String(localized: "receive_unknown_transfer")))
|
||||
Text(L10n.Receive.deleteHistoryDescription(
|
||||
transferName: transferName(for: target) ?? String(localized: L10n.Receive.unknownTransfer)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,14 +139,14 @@ private struct ReceiveTransferRow: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "doc")
|
||||
Image(systemSymbol: .doc)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(.quaternary, in: RoundedRectangle(cornerRadius: 9))
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(transfer.transferName ?? String(localized: "receive_unknown_transfer"))
|
||||
Text(transfer.transferName ?? String(localized: L10n.Receive.unknownTransfer))
|
||||
.font(.body).lineLimit(1)
|
||||
Text("\(formatBytes(transfer.totalSize)) · \(statusLabel(transfer.status))")
|
||||
Text(L10n.Format.separatedPair(first: formatBytes(transfer.totalSize), second: statusLabel(transfer.status)))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
if transfer.status == .receiving, let progress {
|
||||
ProgressRow(labelKey: progress.labelKey, progress: progress.progress, detail: progress.detail)
|
||||
|
||||
@@ -154,7 +154,7 @@ final class SendModel: ObservableObject {
|
||||
}
|
||||
|
||||
func onFilePickFailed(_ reason: String) {
|
||||
messages.error(InvitationError.message(reason.isEmpty ? "selection failed" : reason))
|
||||
messages.error(reason.isEmpty ? InvitationError.selectionFailed : InvitationError.raw(reason))
|
||||
}
|
||||
|
||||
func clearSelectedSource() {
|
||||
@@ -207,6 +207,9 @@ final class SendModel: ObservableObject {
|
||||
func confirmDeleteTransfer() {
|
||||
guard let transferId = state.selectedTransferId, !state.isDeleting else { return }
|
||||
state.isDeleting = true
|
||||
// Close synchronously: the alert's dismiss binding runs async and no-ops while
|
||||
// `isDeleting`, which would otherwise leave the flag true and macOS re-present it.
|
||||
state.isDeleteConfirmationOpen = false
|
||||
Task {
|
||||
let result = await repository.delete(transferId: transferId)
|
||||
switch result {
|
||||
@@ -217,7 +220,32 @@ final class SendModel: ObservableObject {
|
||||
state.receiverHistory = []
|
||||
state.isDeleteConfirmationOpen = false
|
||||
state.isDeleting = false
|
||||
messages.tryShow(UiMessage(text: .resource("transfer_deleted"), tone: .success))
|
||||
messages.tryShow(UiMessage(text: .resource(L10n.Transfer.deleted), tone: .success))
|
||||
case .failure(let error):
|
||||
state.isDeleting = false
|
||||
messages.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
@@ -249,7 +277,7 @@ final class SendModel: ObservableObject {
|
||||
switch result {
|
||||
case .success:
|
||||
_ = await repository.refresh()
|
||||
messages.tryShow(UiMessage(text: .resource("transfer_event_stopped"), tone: .info))
|
||||
messages.tryShow(UiMessage(text: .resource(L10n.Transfer.eventStopped), tone: .info))
|
||||
case .failure(let error):
|
||||
messages.error(error)
|
||||
}
|
||||
@@ -261,10 +289,10 @@ final class SendModel: ObservableObject {
|
||||
func onInvitationResult(_ action: InvitationAction, _ result: Result<Void, Error>) {
|
||||
switch result {
|
||||
case .success:
|
||||
let key: String?
|
||||
let key: String.LocalizationValue?
|
||||
switch action {
|
||||
case .export: key = "transfer_invitation_saved"
|
||||
case .nfc: key = "transfer_nfc_written"
|
||||
case .export: key = L10n.Transfer.invitationSaved
|
||||
case .nfc: key = L10n.Transfer.nfcWritten
|
||||
case .share: key = nil // system share sheet already confirms
|
||||
}
|
||||
if let key { messages.tryShow(UiMessage(text: .resource(key), tone: .success)) }
|
||||
@@ -297,7 +325,14 @@ final class SendModel: ObservableObject {
|
||||
state.transferName = ""
|
||||
state.accessPolicy = .requireApproval
|
||||
state.isSharing = false
|
||||
messages.show(UiMessage(text: .resource("send_transfer_created"), tone: .success))
|
||||
// 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))
|
||||
case .failure(let error):
|
||||
state.isSharing = false
|
||||
messages.error(error)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
|
||||
/// Send screen, rebuilt on native SwiftUI. A grouped `List` of outgoing transfers,
|
||||
/// with the composer and detail panels as native sheets and delete as an alert.
|
||||
@@ -6,6 +7,11 @@ struct SendScreen: View {
|
||||
@ObservedObject var model: SendModel
|
||||
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] {
|
||||
model.coreState.transfers.filter { $0.direction == .send }
|
||||
}
|
||||
@@ -27,11 +33,11 @@ struct SendScreen: View {
|
||||
catalog
|
||||
}
|
||||
}
|
||||
.navigationTitle(Text(LocalizedStringKey("send_title")))
|
||||
.navigationTitle(Text(String(localized: L10n.Send.title)))
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(action: model.openComposer) {
|
||||
Label(String(localized: "button_create_new_transfer"), systemImage: "plus")
|
||||
Label(String(localized: L10n.Button.createNewTransfer), systemSymbol: .plus)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +46,18 @@ struct SendScreen: View {
|
||||
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(
|
||||
isPresented: Binding(get: { model.state.isComposerOpen }, set: { _ in }),
|
||||
@@ -48,8 +66,24 @@ struct SendScreen: View {
|
||||
) {
|
||||
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
|
||||
/// 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).
|
||||
@@ -65,14 +99,14 @@ struct SendScreen: View {
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
Text(LocalizedStringKey("transfer_delete_title")),
|
||||
Text(String(localized: L10n.Transfer.deleteTitle)),
|
||||
isPresented: Binding(get: { model.state.isDeleteConfirmationOpen }, set: { if !$0 { Task { @MainActor in model.dismissDeleteTransfer() } } })
|
||||
) {
|
||||
Button(String(localized: "button_cancel"), role: .cancel, action: model.dismissDeleteTransfer)
|
||||
Button(String(localized: "button_delete_transfer"), role: .destructive, action: model.confirmDeleteTransfer)
|
||||
Button(String(localized: L10n.Button.cancel), role: .cancel, action: model.dismissDeleteTransfer)
|
||||
Button(String(localized: L10n.Button.deleteTransfer), role: .destructive, action: model.confirmDeleteTransfer)
|
||||
} message: {
|
||||
Text(String(format: String(localized: "transfer_delete_description"),
|
||||
transfer.transferName ?? String(localized: "send_new_transfer_title")))
|
||||
Text(L10n.Transfer.deleteDescription(
|
||||
transferName: transfer.transferName ?? String(localized: L10n.Send.newTransferTitle)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,23 +124,45 @@ struct SendScreen: View {
|
||||
)
|
||||
}
|
||||
.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: {
|
||||
Text(LocalizedStringKey("send_transfers_title"))
|
||||
Text(String(localized: L10n.Send.transfersTitle))
|
||||
} footer: {
|
||||
Text(LocalizedStringKey("send_subtitle"))
|
||||
Text(String(localized: L10n.Send.subtitle))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
ContentUnavailableView {
|
||||
Label(String(localized: "send_empty_title"), systemImage: "paperplane")
|
||||
Label(String(localized: L10n.Send.emptyTitle), systemSymbol: .paperplane)
|
||||
} description: {
|
||||
Text(LocalizedStringKey("send_empty_body"))
|
||||
Text(String(localized: L10n.Send.emptyBody))
|
||||
} actions: {
|
||||
Button(action: model.openComposer) {
|
||||
Label(String(localized: "button_create_new_transfer"), systemImage: "plus")
|
||||
Label(String(localized: L10n.Button.createNewTransfer), systemSymbol: .plus)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
@@ -127,21 +183,18 @@ struct SendScreen: View {
|
||||
private func sharingProgress(for transfer: Transfer) -> TransferProgress? {
|
||||
let active = (model.receiversByTransfer[transfer.transferId] ?? []).filter { $0.status == .accepted }
|
||||
if active.isEmpty { return nil }
|
||||
let fractions: [Double] = active.compactMap { receiver -> Double? in
|
||||
let progress = progressForReceiver(events: model.coreState.events, transferId: transfer.transferId,
|
||||
remoteEndpointId: receiver.remoteEndpointId, totalSizeHint: transfer.totalSize)
|
||||
guard progress?.kind == "started" || progress?.kind == "progress" else { return nil }
|
||||
return progress?.progress
|
||||
let fractions = active.compactMap {
|
||||
progressForReceiver(events: model.coreState.events, transferId: transfer.transferId,
|
||||
remoteEndpointId: $0.remoteEndpointId, totalSizeHint: transfer.totalSize)?.progress
|
||||
}
|
||||
guard !fractions.isEmpty else { return nil }
|
||||
let combined = fractions.reduce(0, +) / Double(fractions.count)
|
||||
let combined = fractions.isEmpty ? nil : fractions.reduce(0, +) / Double(fractions.count)
|
||||
if active.count == 1 {
|
||||
return TransferProgress(transferId: transfer.transferId, phase: "transfer", kind: "progress",
|
||||
labelKey: "progress_sending", progress: combined)
|
||||
return TransferProgress(transferId: transfer.transferId, phase: .transfer, kind: .progress,
|
||||
labelKey: L10n.Progress.sending, progress: combined)
|
||||
}
|
||||
return TransferProgress(transferId: transfer.transferId, phase: "transfer", kind: "progress",
|
||||
labelKey: "progress_sending", progress: combined,
|
||||
label: String(format: String(localized: "progress_sending_to_count"), active.count))
|
||||
return TransferProgress(transferId: transfer.transferId, phase: .transfer, kind: .progress,
|
||||
labelKey: L10n.Progress.sending, progress: combined,
|
||||
label: L10n.Progress.sendingToCount(count: active.count))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,19 +210,19 @@ private struct TransferListItem: View {
|
||||
.background(.quaternary, in: RoundedRectangle(cornerRadius: 9))
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack {
|
||||
Text(transfer.transferName ?? String(localized: "send_new_transfer_title"))
|
||||
Text(transfer.transferName ?? String(localized: L10n.Send.newTransferTitle))
|
||||
.font(.body).lineLimit(1)
|
||||
Spacer()
|
||||
StatusPill(label: statusLabel(transfer.status), tone: transfer.status.pillTone)
|
||||
}
|
||||
Text("\(formatBytes(transfer.totalSize)) · \(accessPolicyLabel(transfer.accessPolicy))")
|
||||
Text(L10n.Format.separatedPair(first: formatBytes(transfer.totalSize), second: accessPolicyLabel(transfer.accessPolicy)))
|
||||
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
if let progress, transfer.status == .importing || transfer.status == .sharing {
|
||||
ProgressRow(labelKey: progress.labelKey, progress: progress.progress, detail: progress.detail, labelText: progress.label)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
Image(systemName: "chevron.forward")
|
||||
Image(systemSymbol: .chevronForward)
|
||||
.font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
@@ -184,7 +237,7 @@ struct FileArtwork: View {
|
||||
image.resizable().aspectRatio(contentMode: .fill)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
} else {
|
||||
Image(systemName: "doc")
|
||||
Image(systemSymbol: .doc)
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -192,13 +245,13 @@ struct FileArtwork: View {
|
||||
}
|
||||
|
||||
func statusLabel(_ status: TransferStatus) -> String {
|
||||
String(localized: String.LocalizationValue(statusLabelKey(status)))
|
||||
String(localized: statusLabelKey(status))
|
||||
}
|
||||
|
||||
func accessPolicyLabel(_ policy: ShareAccessPolicy) -> String {
|
||||
switch policy {
|
||||
case .requireApproval: return String(localized: "send_access_approval")
|
||||
case .anyoneWithTransfer: return String(localized: "send_access_anyone")
|
||||
case .requireApproval: return String(localized: L10n.Send.accessApproval)
|
||||
case .anyoneWithTransfer: return String(localized: L10n.Send.accessAnyone)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
|
||||
/// Transfer composer drawer, ported from `feature/send/TransferComposer.kt`.
|
||||
/// Two steps: choose files/folder, then review + name + access policy + share.
|
||||
@@ -23,13 +24,13 @@ struct TransferComposer: View {
|
||||
|
||||
private var chooseStep: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(LocalizedStringKey("send_choose_file_title")).font(.title2).fontWeight(.semibold)
|
||||
Text(LocalizedStringKey("send_choose_file_body"))
|
||||
Text(String(localized: L10n.Send.chooseFileTitle)).font(.title2).fontWeight(.semibold)
|
||||
Text(String(localized: L10n.Send.chooseFileBody))
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: "doc").font(.system(size: 30)).foregroundStyle(.tint)
|
||||
PrimaryButton(title: String(localized: "button_choose_files"), action: model.selectFile).fixedSize()
|
||||
QuietButton(title: String(localized: "button_choose_folder"), action: model.selectFolder)
|
||||
Image(systemSymbol: .doc).font(.system(size: 30)).foregroundStyle(.tint)
|
||||
PrimaryButton(title: String(localized: L10n.Button.chooseFiles), action: model.selectFile).fixedSize()
|
||||
QuietButton(title: String(localized: L10n.Button.chooseFolder), action: model.selectFolder)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(28)
|
||||
@@ -39,9 +40,9 @@ struct TransferComposer: View {
|
||||
|
||||
private var reviewStep: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(LocalizedStringKey("send_review_title")).font(.title2).fontWeight(.semibold)
|
||||
Text(String(localized: L10n.Send.reviewTitle)).font(.title2).fontWeight(.semibold)
|
||||
if state.selectedFiles.count > 1 {
|
||||
Text(String(format: String(localized: "send_selected_files_count"), state.selectedFiles.count))
|
||||
Text(L10n.Send.selectedFilesCount(count: state.selectedFiles.count))
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach(state.selectedFiles) { file in
|
||||
@@ -51,52 +52,64 @@ struct TransferComposer: View {
|
||||
onRemove: { model.removeSelectedFile(file.value) }
|
||||
)
|
||||
}
|
||||
Field(label: String(localized: "field_transfer_name"),
|
||||
Field(label: String(localized: L10n.Field.transferName),
|
||||
value: Binding(get: { state.transferName }, set: { model.setTransferName($0) }))
|
||||
Field(label: String(localized: "field_sender_name"),
|
||||
Field(label: String(localized: L10n.Field.senderName),
|
||||
value: Binding(get: { state.senderName }, set: { model.setSenderName($0) }))
|
||||
Text(LocalizedStringKey("send_access_title")).font(.headline)
|
||||
Text(String(localized: L10n.Send.accessTitle)).font(.headline)
|
||||
PolicyOption(
|
||||
icon: "checkmark.shield", titleKey: "send_access_approval", descKey: "send_access_approval_description",
|
||||
icon: .checkmarkShield, titleKey: L10n.Send.accessApproval, descKey: L10n.Send.accessApprovalDescription,
|
||||
selected: state.accessPolicy == .requireApproval,
|
||||
onTap: { model.setAccessPolicy(.requireApproval) }
|
||||
)
|
||||
PolicyOption(
|
||||
icon: "globe", titleKey: "send_access_anyone", descKey: "send_access_anyone_description",
|
||||
icon: .globe, titleKey: L10n.Send.accessAnyone, descKey: L10n.Send.accessAnyoneDescription,
|
||||
selected: state.accessPolicy == .anyoneWithTransfer,
|
||||
onTap: { model.setAccessPolicy(.anyoneWithTransfer) }
|
||||
)
|
||||
if state.accessPolicy == .anyoneWithTransfer {
|
||||
Label(String(localized: "send_access_anyone_warning"), systemImage: "exclamationmark.triangle.fill")
|
||||
Label(String(localized: L10n.Send.accessAnyoneWarning), systemSymbol: .exclamationmarkTriangleFill)
|
||||
.font(.caption).foregroundStyle(.orange)
|
||||
}
|
||||
actions
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var actions: some View {
|
||||
let shareTitle = state.isSharing
|
||||
? String(localized: "button_sharing_file") : String(localized: "button_share_file")
|
||||
let shareButton = PrimaryButton(
|
||||
title: shareTitle, action: model.createShare,
|
||||
enabled: state.canCreateShare(coreInitialized: model.coreState.isInitialized)
|
||||
)
|
||||
if windowClass == .phone {
|
||||
VStack(spacing: 8) {
|
||||
shareButton
|
||||
QuietButton(title: String(localized: "button_change_files"), action: model.selectFile, enabled: !state.isSharing)
|
||||
QuietButton(title: String(localized: "button_choose_folder"), action: model.selectFolder, enabled: !state.isSharing)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
shareButton.fixedSize()
|
||||
QuietButton(title: String(localized: "button_change_files"), action: model.selectFile, enabled: !state.isSharing)
|
||||
QuietButton(title: String(localized: "button_choose_folder"), action: model.selectFolder, enabled: !state.isSharing)
|
||||
QuietButton(title: String(localized: "button_clear"), action: model.clearSelectedSource, enabled: !state.isSharing)
|
||||
? String(localized: L10n.Button.sharingFile) : String(localized: L10n.Button.shareFile)
|
||||
return VStack(spacing: 10) {
|
||||
PrimaryButton(
|
||||
title: shareTitle, action: model.createShare,
|
||||
enabled: state.canCreateShare(coreInitialized: model.coreState.isInitialized)
|
||||
)
|
||||
// Secondary source actions as an even row of bordered buttons rather than
|
||||
// bare text links, so they read as controls and align with the primary.
|
||||
HStack(spacing: 10) {
|
||||
sourceButton(title: L10n.Button.changeFiles, symbol: .docBadgeArrowUp, action: model.selectFile)
|
||||
sourceButton(title: L10n.Button.chooseFolder, symbol: .folder, action: model.selectFolder)
|
||||
if windowClass != .phone {
|
||||
sourceButton(title: L10n.Button.clear, symbol: .xmark, action: model.clearSelectedSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -116,7 +129,7 @@ private struct SelectedFileCard: View {
|
||||
Spacer()
|
||||
if canRemove {
|
||||
Button(role: .destructive, action: onRemove) {
|
||||
Image(systemName: "trash")
|
||||
Image(systemSymbol: .trash)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.tint(.red)
|
||||
@@ -128,32 +141,32 @@ private struct SelectedFileCard: View {
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
if file.isDirectory { return String(localized: "send_folder_label") }
|
||||
if file.isDirectory { return String(localized: L10n.Send.folderLabel) }
|
||||
if let size = file.sizeBytes { return formatBytes(size) }
|
||||
return String(localized: "send_file_size_unknown")
|
||||
return String(localized: L10n.Send.fileSizeUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PolicyOption: View {
|
||||
let icon: String
|
||||
let titleKey: String
|
||||
let descKey: String
|
||||
let icon: SFSymbol
|
||||
let titleKey: String.LocalizationValue
|
||||
let descKey: String.LocalizationValue
|
||||
let selected: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
Image(systemSymbol: icon)
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
|
||||
.frame(width: 22)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(LocalizedStringKey(titleKey))
|
||||
Text(LocalizedStringKey(descKey)).font(.caption).foregroundStyle(.secondary)
|
||||
Text(String(localized: titleKey))
|
||||
Text(String(localized: descKey)).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||
Image(systemSymbol: selected ? .checkmarkCircleFill : .circle)
|
||||
.foregroundStyle(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.tertiary))
|
||||
}
|
||||
.padding(14)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
import CoreImage.CIFilterBuiltins
|
||||
|
||||
/// Transfer details + drawer panels, ported from `feature/send/TransferDetails.kt`.
|
||||
@@ -23,80 +24,78 @@ struct TransferDetailsView: View {
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
LabeledContent(String(localized: "metadata_status"), value: statusLabel(transfer.status))
|
||||
LabeledContent(String(localized: "metadata_size"), value: formatBytes(transfer.totalSize))
|
||||
LabeledContent(String(localized: "send_access_title"), value: accessPolicyLabel(transfer.accessPolicy))
|
||||
LabeledContent(String(localized: L10n.Metadata.status), value: statusLabel(transfer.status))
|
||||
LabeledContent(String(localized: L10n.Metadata.size), value: formatBytes(transfer.totalSize))
|
||||
LabeledContent(String(localized: L10n.Send.accessTitle), value: accessPolicyLabel(transfer.accessPolicy))
|
||||
} header: {
|
||||
Text(transfer.transferName ?? String(localized: "send_new_transfer_title"))
|
||||
Text(transfer.transferName ?? String(localized: L10n.Send.newTransferTitle))
|
||||
}
|
||||
|
||||
Section {
|
||||
DetailDestination(
|
||||
title: String(localized: "transfer_activity_title"),
|
||||
description: String(localized: "transfer_activity_description"),
|
||||
title: String(localized: L10n.Transfer.activityTitle),
|
||||
description: String(localized: L10n.Transfer.activityDescription),
|
||||
count: events.filter { $0.transferId == transfer.transferId && $0.isMeaningfulActivity }.count,
|
||||
onTap: model.openActivity
|
||||
)
|
||||
DetailDestination(
|
||||
title: String(localized: "transfer_receivers_title"),
|
||||
title: String(localized: L10n.Transfer.receiversTitle),
|
||||
description: receiversDescription(pendingReceivers, completedReceivers),
|
||||
count: pendingReceivers + completedReceivers,
|
||||
onTap: model.openReceivers
|
||||
)
|
||||
if transfer.invitationPresentation != .unavailable {
|
||||
DetailDestination(
|
||||
title: String(localized: "transfer_share_title"),
|
||||
description: String(localized: "transfer_share_description"),
|
||||
count: 0,
|
||||
onTap: model.openShare
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if isActiveShare {
|
||||
Section {
|
||||
Section {
|
||||
if isActiveShare {
|
||||
Button(role: .destructive) {
|
||||
showStopConfirmation = true
|
||||
} label: {
|
||||
Label(String(localized: "send_stop_sharing"), systemImage: "stop.circle")
|
||||
Label(String(localized: L10n.Send.stopSharing), systemSymbol: .stopCircle)
|
||||
}
|
||||
}
|
||||
Button(role: .destructive, action: model.requestDeleteTransfer) {
|
||||
Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(Text(LocalizedStringKey("send_transfer_details_title")))
|
||||
.navigationTitle(Text(String(localized: L10n.Send.transferDetailsTitle)))
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(role: .destructive, action: model.requestDeleteTransfer) {
|
||||
Image(systemName: "trash")
|
||||
Button(action: model.openShare) {
|
||||
Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp)
|
||||
}
|
||||
.help(String(localized: L10n.Transfer.shareTitle))
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
Text(LocalizedStringKey("send_stop_sharing")),
|
||||
Text(String(localized: L10n.Send.stopSharing)),
|
||||
isPresented: $showStopConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(String(localized: "send_stop_sharing"), role: .destructive) {
|
||||
Button(String(localized: L10n.Send.stopSharing), role: .destructive) {
|
||||
model.stopSharing(transferId: transfer.transferId)
|
||||
}
|
||||
Button(String(localized: "button_cancel"), role: .cancel) {}
|
||||
Button(String(localized: L10n.Button.cancel), role: .cancel) {}
|
||||
} message: {
|
||||
Text(LocalizedStringKey("send_stop_sharing_description"))
|
||||
Text(String(localized: L10n.Send.stopSharingDescription))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func receiversDescription(_ pending: Int, _ completed: Int) -> String {
|
||||
if pending > 0 && completed > 0 {
|
||||
return "\(String(format: String(localized: "transfer_receivers_pending"), pending)) · \(String(format: String(localized: "transfer_receivers_completed_count"), completed))"
|
||||
return L10n.Format.separatedPair(
|
||||
first: L10n.Transfer.receiversPending(count: pending),
|
||||
second: L10n.Transfer.receiversCompletedCount(count: completed))
|
||||
}
|
||||
if pending > 0 { return String(format: String(localized: "transfer_receivers_pending"), pending) }
|
||||
if completed > 0 { return String(format: String(localized: "transfer_receivers_completed_count"), completed) }
|
||||
return String(localized: "transfer_receivers_description")
|
||||
if pending > 0 { return L10n.Transfer.receiversPending(count: pending) }
|
||||
if completed > 0 { return L10n.Transfer.receiversCompletedCount(count: completed) }
|
||||
return String(localized: L10n.Transfer.receiversDescription)
|
||||
}
|
||||
|
||||
private struct DetailDestination: View {
|
||||
@@ -113,11 +112,11 @@ private struct DetailDestination: View {
|
||||
}
|
||||
Spacer()
|
||||
if count > 0 {
|
||||
Text("\(count)")
|
||||
Text(verbatim: "\(count)")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Image(systemName: "chevron.forward")
|
||||
Image(systemSymbol: .chevronForward)
|
||||
.font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
@@ -173,13 +172,13 @@ struct TransferActivityPanel: View {
|
||||
let visible = events
|
||||
.filter { $0.transferId == transferId && $0.isMeaningfulActivity }
|
||||
.sorted { $0.timestamp > $1.timestamp }
|
||||
PanelContainer(title: String(localized: "transfer_activity_title")) {
|
||||
PanelContainer(title: String(localized: L10n.Transfer.activityTitle)) {
|
||||
if visible.isEmpty {
|
||||
Text(LocalizedStringKey("transfer_no_activity")).foregroundStyle(colors.foregroundLighter)
|
||||
Text(String(localized: L10n.Transfer.noActivity)).foregroundStyle(colors.foregroundLighter)
|
||||
} else {
|
||||
ForEach(Array(visible.enumerated()), id: \.offset) { index, event in
|
||||
if index > 0 { Divider().overlay(colors.borderDefault) }
|
||||
Text(LocalizedStringKey(event.activityTitleKey))
|
||||
Text(String(localized: event.activityTitleKey))
|
||||
.fontWeight(.medium).padding(.vertical, 14)
|
||||
}
|
||||
}
|
||||
@@ -196,11 +195,11 @@ struct ReceiverHistoryPanel: View {
|
||||
let onCancel: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
PanelContainer(title: String(localized: "transfer_receivers_title")) {
|
||||
PanelContainer(title: String(localized: L10n.Transfer.receiversTitle)) {
|
||||
if loading {
|
||||
ProgressView().frame(maxWidth: .infinity).padding(40)
|
||||
} else if receivers.isEmpty {
|
||||
Text(LocalizedStringKey("transfer_no_receivers")).foregroundStyle(colors.foregroundLighter)
|
||||
Text(String(localized: L10n.Transfer.noReceivers)).foregroundStyle(colors.foregroundLighter)
|
||||
} else {
|
||||
ForEach(Array(receivers.enumerated()), id: \.element.id) { index, receiver in
|
||||
if index > 0 { Divider().overlay(colors.borderDefault) }
|
||||
@@ -234,9 +233,10 @@ private struct ReceiverRow: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let name = receiver.receiverName ?? receiver.receiverDeviceName ?? String(localized: "transfer_nearby_device")
|
||||
let name = receiver.receiverName ?? receiver.receiverDeviceName ?? String(localized: L10n.Transfer.nearbyDevice)
|
||||
let showLive = sendProgress != nil && receiver.status != .completed
|
||||
&& receiver.status != .refused && receiver.status != .expired
|
||||
&& receiver.status != .failed
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(name).font(VniType.bodyLarge).lineLimit(1)
|
||||
@@ -246,12 +246,13 @@ private struct ReceiverRow: View {
|
||||
if showLive, let sendProgress {
|
||||
ProgressRow(labelKey: sendProgress.labelKey, progress: sendProgress.progress, detail: sendProgress.detail, labelText: sendProgress.label)
|
||||
} else {
|
||||
Text(LocalizedStringKey(receiver.status.statusTextKey))
|
||||
Text(String(localized: receiver.status.statusTextKey))
|
||||
.font(VniType.bodySmall).fontWeight(.medium)
|
||||
.foregroundStyle(receiver.status.statusColor(colors))
|
||||
}
|
||||
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)
|
||||
@@ -259,7 +260,7 @@ private struct ReceiverRow: View {
|
||||
Button(role: .destructive) {
|
||||
onCancel(receiver.id)
|
||||
} label: {
|
||||
Text(LocalizedStringKey("button_refuse"))
|
||||
Text(String(localized: L10n.Button.refuse))
|
||||
.font(VniType.bodySmall)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
@@ -277,24 +278,22 @@ struct TransferSharePanel: View {
|
||||
let transfer: Transfer
|
||||
|
||||
var body: some View {
|
||||
PanelContainer(title: String(localized: "transfer_share_title")) {
|
||||
PanelContainer(title: String(localized: L10n.Transfer.shareTitle)) {
|
||||
switch transfer.invitationPresentation {
|
||||
case .ready(let ticket):
|
||||
let qrImage = QRCode.generate(from: ticket)
|
||||
qrCard(image: qrImage)
|
||||
if qrImage != nil {
|
||||
Text(LocalizedStringKey("transfer_scan_qr"))
|
||||
Text(String(localized: L10n.Transfer.scanQr))
|
||||
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
ShareActionsView(model: model, transfer: transfer, ticket: ticket)
|
||||
case .preparing:
|
||||
Text(LocalizedStringKey("transfer_event_preparing")).foregroundStyle(colors.foregroundLighter)
|
||||
Text(String(localized: L10n.Transfer.eventPreparing)).foregroundStyle(colors.foregroundLighter)
|
||||
case .unavailable:
|
||||
Text(LocalizedStringKey(
|
||||
transfer.status == .failed ? "transfer_event_failed" : "transfer_event_stopped"
|
||||
))
|
||||
.foregroundStyle(colors.foregroundLighter)
|
||||
Text(String(localized: transfer.status == .failed ? L10n.Transfer.eventFailed : L10n.Transfer.eventStopped))
|
||||
.foregroundStyle(colors.foregroundLighter)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -305,9 +304,9 @@ struct TransferSharePanel: View {
|
||||
image.interpolation(.none).resizable().scaledToFit().padding(14)
|
||||
} else {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "qrcode")
|
||||
Image(systemSymbol: .qrcode)
|
||||
.font(.system(size: 36, weight: .medium))
|
||||
Text(LocalizedStringKey("transfer_qr_unavailable"))
|
||||
Text(String(localized: L10n.Transfer.qrUnavailable))
|
||||
.font(VniType.bodySmall)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
@@ -373,38 +372,39 @@ extension CoreEventModel {
|
||||
"receiver-refused", "receiver-completed", "share-stopped", "failed"].contains(kind)
|
||||
}
|
||||
|
||||
var activityTitleKey: String {
|
||||
if phase == "import" && kind == "started" { return "transfer_event_preparing" }
|
||||
if phase == "ticket" && kind == "created" { return "transfer_event_ready" }
|
||||
if phase == "network" { return "transfer_event_connecting" }
|
||||
if phase == "download" { return "transfer_event_downloading" }
|
||||
if phase == "export" { return "transfer_event_saving" }
|
||||
if kind == "receiver-requested" { return "transfer_event_requested" }
|
||||
if kind == "receiver-accepted" || kind == "receiver-auto-approved" { return "transfer_event_approved" }
|
||||
if kind == "receiver-refused" { return "transfer_event_refused" }
|
||||
if kind == "receiver-completed" { return "transfer_event_completed" }
|
||||
if kind == "share-stopped" || (phase == "lifecycle" && kind == "cancelled") { return "transfer_event_stopped" }
|
||||
if kind == "failed" { return "transfer_event_failed" }
|
||||
return "transfer_event_updated"
|
||||
var activityTitleKey: String.LocalizationValue {
|
||||
if phase == "import" && kind == "started" { return L10n.Transfer.eventPreparing }
|
||||
if phase == "ticket" && kind == "created" { return L10n.Transfer.eventReady }
|
||||
if phase == "network" { return L10n.Transfer.eventConnecting }
|
||||
if phase == "download" { return L10n.Transfer.eventDownloading }
|
||||
if phase == "export" { return L10n.Transfer.eventSaving }
|
||||
if kind == "receiver-requested" { return L10n.Transfer.eventRequested }
|
||||
if kind == "receiver-accepted" || kind == "receiver-auto-approved" { return L10n.Transfer.eventApproved }
|
||||
if kind == "receiver-refused" { return L10n.Transfer.eventRefused }
|
||||
if kind == "receiver-completed" { return L10n.Transfer.eventCompleted }
|
||||
if kind == "share-stopped" || (phase == "lifecycle" && kind == "cancelled") { return L10n.Transfer.eventStopped }
|
||||
if kind == "failed" { return L10n.Transfer.eventFailed }
|
||||
return L10n.Transfer.eventUpdated
|
||||
}
|
||||
}
|
||||
|
||||
extension ReceiverDeliveryStatus {
|
||||
var statusTextKey: String {
|
||||
var statusTextKey: String.LocalizationValue {
|
||||
switch self {
|
||||
case .requested: return "transfer_receiver_requested"
|
||||
case .accepted: return "transfer_receiver_accepted"
|
||||
case .refused: return "transfer_receiver_refused"
|
||||
case .expired: return "transfer_receiver_expired"
|
||||
case .completed: return "transfer_receiver_completed"
|
||||
case .unknown: return "transfer_receiver_unknown"
|
||||
case .requested: return L10n.Transfer.receiverRequested
|
||||
case .accepted: return L10n.Transfer.receiverAccepted
|
||||
case .refused: return L10n.Transfer.receiverRefused
|
||||
case .expired: return L10n.Transfer.receiverExpired
|
||||
case .completed: return L10n.Transfer.receiverCompleted
|
||||
case .failed: return L10n.Transfer.receiverFailed
|
||||
case .unknown: return L10n.Transfer.receiverUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func statusColor(_ colors: VniDropColors) -> Color {
|
||||
switch self {
|
||||
case .completed: return colors.brandDefault
|
||||
case .refused, .expired: return colors.destructiveDefault
|
||||
case .refused, .expired, .failed: return colors.destructiveDefault
|
||||
default: return colors.foregroundLighter
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import SwiftUI
|
||||
|
||||
enum NfcShareAvailability { case available, unavailable, hidden }
|
||||
|
||||
/// Invitation delivery actions shared by the native Apple feature models.
|
||||
/// Invitation delivery actions, ported from `TransferShareActions` (iosMain).
|
||||
/// Platform implementations perform export, native share, and NFC write.
|
||||
@MainActor
|
||||
protocol TransferShareActions: AnyObject {
|
||||
@@ -30,7 +30,7 @@ struct ShareActionsView: View {
|
||||
VStack(spacing: 12) {
|
||||
if actions.nfcAvailability != .hidden {
|
||||
SecondaryButton(
|
||||
title: writingNfc ? String(localized: "transfer_nfc_waiting") : String(localized: "button_write_nfc"),
|
||||
title: writingNfc ? String(localized: L10n.Transfer.nfcWaiting) : String(localized: L10n.Button.writeNfc),
|
||||
action: {
|
||||
writingNfc = true
|
||||
actions.writeInvitationToNfc(ticket: ticket) { result in
|
||||
@@ -41,16 +41,16 @@ struct ShareActionsView: View {
|
||||
enabled: actions.nfcAvailability == .available && !writingNfc
|
||||
)
|
||||
if actions.nfcAvailability == .unavailable {
|
||||
Text(LocalizedStringKey("transfer_nfc_unavailable"))
|
||||
Text(String(localized: L10n.Transfer.nfcUnavailable))
|
||||
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||
}
|
||||
}
|
||||
SecondaryButton(title: String(localized: "button_download_invitation"), action: {
|
||||
SecondaryButton(title: String(localized: L10n.Button.downloadInvitation), action: {
|
||||
actions.exportInvitation(ticket: ticket, transferName: transfer.transferName ?? "") {
|
||||
model.onInvitationResult(.export, $0)
|
||||
}
|
||||
})
|
||||
PrimaryButton(title: String(localized: "button_native_share"), action: {
|
||||
PrimaryButton(title: String(localized: L10n.Button.nativeShare), action: {
|
||||
actions.shareInvitation(ticket: ticket, transferName: transfer.transferName ?? "") {
|
||||
model.onInvitationResult(.share, $0)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ protocol BugReportService {
|
||||
/// Offline-safe no-op used until the diagnostics transport is configured.
|
||||
struct NoopBugReportService: BugReportService {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -12,16 +12,16 @@ enum SettingsSection: Hashable {
|
||||
case about
|
||||
case bugReport
|
||||
|
||||
var titleKey: String {
|
||||
var titleKey: String.LocalizationValue {
|
||||
switch self {
|
||||
case .overview: return "settings_title"
|
||||
case .preferences: return "preferences_title"
|
||||
case .appearance: return "appearance_title"
|
||||
case .notifications: return "notifications_title"
|
||||
case .network: return "settings_network_title"
|
||||
case .storage: return "storage_title"
|
||||
case .about: return "about_title"
|
||||
case .bugReport: return "about_bug_report"
|
||||
case .overview: return L10n.Settings.title
|
||||
case .preferences: return L10n.Preferences.title
|
||||
case .appearance: return L10n.Appearance.title
|
||||
case .notifications: return L10n.Notifications.title
|
||||
case .network: return L10n.Settings.networkTitle
|
||||
case .storage: return L10n.Storage.title
|
||||
case .about: return L10n.About.title
|
||||
case .bugReport: return L10n.About.bugReport
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,6 @@ struct SettingsState: Equatable {
|
||||
var isValidatingFolder = false
|
||||
var supportsCustomReceiveFolders = true
|
||||
var themeMode: ThemeMode = .system
|
||||
var notificationsEnabled = false
|
||||
var notificationPermission: NotificationPermission = .notDetermined
|
||||
var diagnosticsEnabled = false
|
||||
var relayMode: RelayPreferenceMode = .automatic
|
||||
@@ -53,7 +52,7 @@ struct SettingsState: Equatable {
|
||||
var isApplyingRelayConfiguration = false
|
||||
var hasActiveNetworkWork = false
|
||||
var endpointId: String?
|
||||
var relayApplyErrorKey: String?
|
||||
var relayApplyErrorKey: String.LocalizationValue?
|
||||
var deviceInfo: DeviceInfo?
|
||||
var appVersion = ""
|
||||
var isLoadingDeviceInfo = false
|
||||
@@ -66,14 +65,16 @@ struct SettingsState: Equatable {
|
||||
var bugLogPreviewBytes = 0
|
||||
var storage: StorageBreakdown?
|
||||
var isCalculatingStorage = false
|
||||
var storageLoadFailed = false
|
||||
var isDeletingTransfers = false
|
||||
var isCleaningStorage = false
|
||||
|
||||
static func == (lhs: SettingsState, rhs: SettingsState) -> Bool {
|
||||
lhs.selectedSection == rhs.selectedSection && lhs.username == rhs.username
|
||||
&& lhs.receiveFolder == rhs.receiveFolder && lhs.folderAccessStatus == rhs.folderAccessStatus
|
||||
&& lhs.isValidatingFolder == rhs.isValidatingFolder
|
||||
&& lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders
|
||||
&& lhs.themeMode == rhs.themeMode && lhs.notificationsEnabled == rhs.notificationsEnabled
|
||||
&& lhs.themeMode == rhs.themeMode
|
||||
&& lhs.notificationPermission == rhs.notificationPermission
|
||||
&& lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion
|
||||
&& lhs.relayMode == rhs.relayMode && lhs.relayURLs == rhs.relayURLs
|
||||
@@ -89,7 +90,9 @@ struct SettingsState: Equatable {
|
||||
&& lhs.bugIncludeLogs == rhs.bugIncludeLogs && lhs.isSubmittingBugReport == rhs.isSubmittingBugReport
|
||||
&& lhs.bugLogPreviewBytes == rhs.bugLogPreviewBytes
|
||||
&& lhs.storage == rhs.storage && lhs.isCalculatingStorage == rhs.isCalculatingStorage
|
||||
&& lhs.storageLoadFailed == rhs.storageLoadFailed
|
||||
&& lhs.isDeletingTransfers == rhs.isDeletingTransfers
|
||||
&& lhs.isCleaningStorage == rhs.isCleaningStorage
|
||||
&& lhs.deviceInfo?.operatingSystem == rhs.deviceInfo?.operatingSystem
|
||||
}
|
||||
}
|
||||
@@ -110,7 +113,6 @@ final class SettingsModel: ObservableObject {
|
||||
private let bugReports: BugReportService
|
||||
private let diagnosticsIncluded: Bool
|
||||
|
||||
private var enableNotificationsAfterSettings = false
|
||||
private var usernamePersistTask: Task<Void, Never>?
|
||||
private var hasLocalUsernameDraft = false
|
||||
private var hasRelayConfigurationDraft = false
|
||||
@@ -149,7 +151,6 @@ final class SettingsModel: ObservableObject {
|
||||
self.state.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username
|
||||
self.state.receiveFolder = folder
|
||||
self.state.themeMode = prefs.themeMode
|
||||
self.state.notificationsEnabled = prefs.notificationsEnabled
|
||||
self.state.diagnosticsEnabled = prefs.diagnosticsEnabled
|
||||
if !self.hasRelayConfigurationDraft {
|
||||
self.state.relayMode = prefs.relayConfiguration.mode
|
||||
@@ -168,7 +169,7 @@ final class SettingsModel: ObservableObject {
|
||||
|| coreState.transfers.contains(where: { $0.status.isActiveTransfer })
|
||||
self.state.hasActiveNetworkWork = hasActiveWork
|
||||
self.state.endpointId = coreState.status?.endpointId
|
||||
if !hasActiveWork && self.state.relayApplyErrorKey == "relay_apply_active_transfers" {
|
||||
if !hasActiveWork && self.state.relayApplyErrorKey == L10n.Relay.applyActiveTransfers {
|
||||
self.state.relayApplyErrorKey = nil
|
||||
}
|
||||
}
|
||||
@@ -205,29 +206,26 @@ final class SettingsModel: ObservableObject {
|
||||
}
|
||||
|
||||
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 setNotificationsEnabled(_ enabled: Bool) {
|
||||
/// Whether the current receive folder is the platform default (so the reset
|
||||
/// action can be hidden when it would be a no-op). Compared by location, not
|
||||
/// display name, which can differ once resolved.
|
||||
var isUsingDefaultReceiveFolder: Bool {
|
||||
guard let folder = state.receiveFolder else { return true }
|
||||
let fallback = fileSystemService.defaultReceiveFolder()
|
||||
return folder.kind == fallback.kind && folder.value == fallback.value
|
||||
}
|
||||
|
||||
/// Ask the OS for notification permission. This is the only time the app can
|
||||
/// grant it; disabling or fine-tuning afterwards happens in the Settings app.
|
||||
func requestNotifications() {
|
||||
Task {
|
||||
if !enabled {
|
||||
preferences.setNotificationsEnabled(false)
|
||||
notifications.cancelAll()
|
||||
return
|
||||
}
|
||||
let permission = await notifications.requestPermission()
|
||||
state.notificationPermission = permission
|
||||
if permission == .granted {
|
||||
await enableNotifications()
|
||||
} else {
|
||||
preferences.setNotificationsEnabled(false)
|
||||
let key = permission == .unsupported ? "notifications_unsupported" : "notifications_permission_denied"
|
||||
messages.show(UiMessage(
|
||||
text: .resource(key),
|
||||
tone: .warning,
|
||||
actionLabel: permission == .denied ? .resource("button_open_settings") : nil,
|
||||
onAction: permission == .denied ? { self.openNotificationSettings() } : nil
|
||||
))
|
||||
if permission == .unsupported {
|
||||
messages.show(UiMessage(text: .resource(L10n.Notifications.unsupported), tone: .warning))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,7 +235,7 @@ final class SettingsModel: ObservableObject {
|
||||
Task {
|
||||
preferences.setDiagnosticsEnabled(enabled)
|
||||
messages.show(UiMessage(
|
||||
text: .resource(enabled ? "diagnostics_enabled_message" : "diagnostics_disabled_message"),
|
||||
text: .resource(enabled ? L10n.Diagnostics.enabledMessage : L10n.Diagnostics.disabledMessage),
|
||||
tone: .success
|
||||
))
|
||||
}
|
||||
@@ -298,8 +296,8 @@ final class SettingsModel: ObservableObject {
|
||||
|| coreState.transfers.contains(where: { $0.status.isActiveTransfer })
|
||||
guard !hasActiveWork else {
|
||||
state.hasActiveNetworkWork = true
|
||||
state.relayApplyErrorKey = "relay_apply_active_transfers"
|
||||
messages.show(UiMessage(text: .resource("relay_apply_active_transfers"), tone: .warning))
|
||||
state.relayApplyErrorKey = L10n.Relay.applyActiveTransfers
|
||||
messages.show(UiMessage(text: .resource(L10n.Relay.applyActiveTransfers), tone: .warning))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -318,16 +316,16 @@ final class SettingsModel: ObservableObject {
|
||||
preferences.setRelayConfiguration(configuration)
|
||||
state.isApplyingRelayConfiguration = false
|
||||
state.relayConfigurationIsDirty = false
|
||||
messages.show(UiMessage(text: .resource("relay_settings_applied"), tone: .success))
|
||||
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 = "relay_apply_active_transfers"
|
||||
state.relayApplyErrorKey = L10n.Relay.applyActiveTransfers
|
||||
case .transitionInProgress:
|
||||
state.relayApplyErrorKey = "relay_apply_failed"
|
||||
state.relayApplyErrorKey = L10n.Relay.applyFailed
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -337,11 +335,11 @@ final class SettingsModel: ObservableObject {
|
||||
)
|
||||
state.isApplyingRelayConfiguration = false
|
||||
if case .success = rollbackResult {
|
||||
state.relayApplyErrorKey = "relay_apply_failed"
|
||||
messages.show(UiMessage(text: .resource("relay_apply_failed"), tone: .error))
|
||||
state.relayApplyErrorKey = L10n.Relay.applyFailed
|
||||
messages.show(UiMessage(text: .resource(L10n.Relay.applyFailed), tone: .error))
|
||||
} else {
|
||||
state.relayApplyErrorKey = "relay_restore_failed"
|
||||
messages.show(UiMessage(text: .resource("relay_restore_failed"), tone: .error))
|
||||
state.relayApplyErrorKey = L10n.Relay.restoreFailed
|
||||
messages.show(UiMessage(text: .resource(L10n.Relay.restoreFailed), tone: .error))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -369,11 +367,11 @@ final class SettingsModel: ObservableObject {
|
||||
let what = snapshot.bugWhatHappened.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let expected = snapshot.bugExpected.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if what.isEmpty {
|
||||
messages.show(UiMessage(text: .resource("bug_report_missing_what"), tone: .warning))
|
||||
messages.show(UiMessage(text: .resource(L10n.Bug.reportMissingWhat), tone: .warning))
|
||||
return
|
||||
}
|
||||
if expected.isEmpty {
|
||||
messages.show(UiMessage(text: .resource("bug_report_missing_expected"), tone: .warning))
|
||||
messages.show(UiMessage(text: .resource(L10n.Bug.reportMissingExpected), tone: .warning))
|
||||
return
|
||||
}
|
||||
state.isSubmittingBugReport = true
|
||||
@@ -392,58 +390,55 @@ final class SettingsModel: ObservableObject {
|
||||
state.bugSteps = ""
|
||||
state.bugContact = ""
|
||||
state.bugIncludeLogs = true
|
||||
messages.show(UiMessage(text: .resource("bug_report_submitted"), tone: .success))
|
||||
messages.show(UiMessage(text: .resource(L10n.Bug.reportSubmitted), tone: .success))
|
||||
onSuccess()
|
||||
case .failure:
|
||||
state.isSubmittingBugReport = false
|
||||
messages.show(UiMessage(text: .resource("bug_report_submit_failed"), tone: .error))
|
||||
messages.show(UiMessage(text: .resource(L10n.Bug.reportSubmitFailed), tone: .error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func openNotificationSettings() {
|
||||
Task {
|
||||
enableNotificationsAfterSettings = true
|
||||
let result = await notifications.openSettings()
|
||||
if case .failure = result {
|
||||
enableNotificationsAfterSettings = false
|
||||
messages.show(UiMessage(text: .resource("notifications_settings_open_failed"), tone: .error))
|
||||
if case .failure = await notifications.openSettings() {
|
||||
messages.show(UiMessage(text: .resource(L10n.Notifications.settingsOpenFailed), tone: .error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-read the OS permission (called on appear and when returning to the
|
||||
/// foreground, e.g. after a trip to Settings) so the toggle stays in sync.
|
||||
func refreshNotificationPermission() {
|
||||
Task {
|
||||
let permission = await notifications.refreshPermission()
|
||||
state.notificationPermission = permission
|
||||
if enableNotificationsAfterSettings {
|
||||
enableNotificationsAfterSettings = false
|
||||
if permission == .granted { await enableNotifications() }
|
||||
} else if permission != .granted && state.notificationsEnabled {
|
||||
preferences.setNotificationsEnabled(false)
|
||||
notifications.cancelAll()
|
||||
}
|
||||
state.notificationPermission = await notifications.refreshPermission()
|
||||
}
|
||||
}
|
||||
|
||||
private func enableNotifications() async {
|
||||
preferences.setNotificationsEnabled(true)
|
||||
messages.show(UiMessage(text: .resource("notifications_enabled_message"), tone: .success))
|
||||
}
|
||||
|
||||
// MARK: - Storage
|
||||
|
||||
/// Recomputes the on-disk usage breakdown off the main actor.
|
||||
/// Recomputes the on-disk usage breakdown off the main actor. Safe to call
|
||||
/// before the core is ready: it keeps the spinner up and waits for the core to
|
||||
/// finish initializing (it starts asynchronously at launch) rather than bailing.
|
||||
func loadStorageUsage() {
|
||||
if state.isCalculatingStorage { return }
|
||||
state.isCalculatingStorage = true
|
||||
state.storageLoadFailed = false
|
||||
let tempDir = NSTemporaryDirectory()
|
||||
Task {
|
||||
// The core initializes asynchronously at launch; poll briefly so opening
|
||||
// Storage early doesn't leave the summary stuck.
|
||||
var attempts = 0
|
||||
while !repository.state.isInitialized && attempts < 100 {
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
attempts += 1
|
||||
}
|
||||
let coreResult = await repository.storageUsage()
|
||||
let artifactsResult = await repository.receivedArtifacts()
|
||||
guard case .success(let core) = coreResult,
|
||||
case .success(let artifacts) = artifactsResult else {
|
||||
state.isCalculatingStorage = false
|
||||
state.storageLoadFailed = true
|
||||
return
|
||||
}
|
||||
let diskSizes = await Task.detached {
|
||||
@@ -478,13 +473,90 @@ final class SettingsModel: ObservableObject {
|
||||
state.isDeletingTransfers = false
|
||||
if failures == 0 {
|
||||
loadStorageUsage()
|
||||
messages.show(UiMessage(text: .resource("storage_transfers_deleted"), tone: .success))
|
||||
messages.show(UiMessage(text: .resource(L10n.Storage.transfersDeleted), tone: .success))
|
||||
} else {
|
||||
messages.error(InvitationError.message("Could not delete \(failures) transfer records"))
|
||||
messages.error(InvitationError.deleteRecordsFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reclaims disk space the core's transfer deletion doesn't touch: the app's
|
||||
/// temporary directory (leftover picker/staging copies) and any stray `.Trash`
|
||||
/// folders that accumulate inside app-owned directories. Never touches received
|
||||
/// files, the core database, or user-chosen receive folders.
|
||||
func freeUpSpace() {
|
||||
if state.isCleaningStorage { return }
|
||||
// Purging staging while a transfer is mid-flight could break it.
|
||||
let hasActive = repository.state.transfers.contains {
|
||||
$0.status == .sharing || $0.status == .importing || $0.status == .receiving
|
||||
}
|
||||
if hasActive {
|
||||
messages.tryShow(UiMessage(text: .resource(L10n.Storage.cleanupBusy), tone: .warning))
|
||||
return
|
||||
}
|
||||
state.isCleaningStorage = true
|
||||
let tempDir = NSTemporaryDirectory()
|
||||
let dataDir = environment.defaultCoreDataDir
|
||||
// Only clean the receive folder's trash when it is app-owned (iOS fixed
|
||||
// Documents), never a user-chosen macOS folder like ~/Downloads.
|
||||
let receiveTrashRoot = fileSystemService.supportsCustomReceiveFolders ? nil : state.receiveFolder?.value
|
||||
Task {
|
||||
let freed = await Task.detached {
|
||||
SettingsModel.reclaimJunk(tempDir: tempDir, dataDir: dataDir, receiveTrashRoot: receiveTrashRoot)
|
||||
}.value
|
||||
state.isCleaningStorage = false
|
||||
loadStorageUsage()
|
||||
messages.show(UiMessage(
|
||||
text: .dynamic(L10n.Storage.cleanupFreed(size: formatBytes(freed))),
|
||||
tone: .success
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes temp-directory contents and `.Trash` folders under the given roots,
|
||||
/// returning the number of bytes reclaimed. Runs off the main actor.
|
||||
nonisolated static func reclaimJunk(tempDir: String, dataDir: String, receiveTrashRoot: String?) -> UInt64 {
|
||||
let fm = FileManager.default
|
||||
var freed: UInt64 = 0
|
||||
// Empty the temporary directory.
|
||||
if let entries = try? fm.contentsOfDirectory(atPath: tempDir) {
|
||||
for name in entries {
|
||||
let path = (tempDir as NSString).appendingPathComponent(name)
|
||||
freed += itemSize(path)
|
||||
try? fm.removeItem(atPath: path)
|
||||
}
|
||||
}
|
||||
// Remove stray `.Trash` folders inside app-owned directories.
|
||||
for root in [dataDir, receiveTrashRoot].compactMap({ $0 }) {
|
||||
for trash in trashDirectories(under: root) {
|
||||
freed += directorySize(trash)
|
||||
try? fm.removeItem(atPath: trash)
|
||||
}
|
||||
}
|
||||
return freed
|
||||
}
|
||||
|
||||
/// Paths of every directory named `.Trash` under `root` (not descending into them).
|
||||
private nonisolated static func trashDirectories(under root: String) -> [String] {
|
||||
let url = URL(fileURLWithPath: root, isDirectory: true)
|
||||
guard let enumerator = FileManager.default.enumerator(
|
||||
at: url, includingPropertiesForKeys: [.isDirectoryKey]
|
||||
) else { return [] }
|
||||
var result: [String] = []
|
||||
for case let fileURL as URL in enumerator where fileURL.lastPathComponent == ".Trash" {
|
||||
result.append(fileURL.path)
|
||||
enumerator.skipDescendants()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Allocated size of a file or directory (0 if missing).
|
||||
private nonisolated static func itemSize(_ path: String) -> UInt64 {
|
||||
var isDirectory: ObjCBool = false
|
||||
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) else { return 0 }
|
||||
return isDirectory.boolValue ? directorySize(path) : fileSize(path)
|
||||
}
|
||||
|
||||
nonisolated static func fileSize(_ path: String) -> UInt64 {
|
||||
let values = try? URL(fileURLWithPath: path).resourceValues(
|
||||
forKeys: [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileSizeKey]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
|
||||
/// Settings screen, rebuilt on a native `Form` with `NavigationStack` push
|
||||
/// navigation. The model stays the source of truth via a derived path binding.
|
||||
@@ -23,39 +24,54 @@ struct SettingsScreen: View {
|
||||
var body: some View {
|
||||
NavigationStack(path: path) {
|
||||
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 {
|
||||
NavigationLink(value: SettingsSection.preferences) {
|
||||
SettingsRow(icon: "person.crop.circle", title: String(localized: "preferences_title"), value: model.state.username)
|
||||
SettingsRow(icon: .personCropCircle, title: String(localized: L10n.Preferences.title), value: model.state.username)
|
||||
}
|
||||
NavigationLink(value: SettingsSection.appearance) {
|
||||
SettingsRow(icon: "sun.max", title: String(localized: "appearance_title"), value: themeModeLabel(model.state.themeMode))
|
||||
SettingsRow(icon: .sunMax, title: String(localized: L10n.Appearance.title), value: themeModeLabel(model.state.themeMode))
|
||||
}
|
||||
}
|
||||
Section {
|
||||
NavigationLink(value: SettingsSection.notifications) {
|
||||
SettingsRow(icon: "bell", title: String(localized: "notifications_title"), value: nil)
|
||||
SettingsRow(icon: .bell, title: String(localized: L10n.Notifications.title), value: nil)
|
||||
}
|
||||
NavigationLink(value: SettingsSection.storage) {
|
||||
SettingsRow(icon: "internaldrive", title: String(localized: "storage_title"), value: nil)
|
||||
SettingsRow(icon: .internaldrive, title: String(localized: L10n.Storage.title), value: nil)
|
||||
}
|
||||
}
|
||||
Section(String(localized: "settings_advanced_title")) {
|
||||
Section(String(localized: L10n.Settings.advancedTitle)) {
|
||||
NavigationLink(value: SettingsSection.network) {
|
||||
SettingsRow(
|
||||
icon: "network",
|
||||
title: String(localized: "settings_network_title"),
|
||||
icon: .network,
|
||||
title: String(localized: L10n.Settings.networkTitle),
|
||||
value: relayModeLabel(model.state.relayMode)
|
||||
)
|
||||
}
|
||||
}
|
||||
Section {
|
||||
NavigationLink(value: SettingsSection.about) {
|
||||
SettingsRow(icon: "info.circle", title: String(localized: "about_title"), value: nil)
|
||||
SettingsRow(icon: .infoCircle, title: String(localized: L10n.About.title), value: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(Text(LocalizedStringKey("settings_title")))
|
||||
.navigationTitle(Text(String(localized: L10n.Settings.title)))
|
||||
.navigationDestination(for: SettingsSection.self) { section in
|
||||
sectionForm(section)
|
||||
}
|
||||
@@ -68,7 +84,7 @@ struct SettingsScreen: View {
|
||||
SettingsSectionContent(model: model, section: section)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(Text(LocalizedStringKey(section.titleKey)))
|
||||
.navigationTitle(Text(String(localized: section.titleKey)))
|
||||
|
||||
if section == .about {
|
||||
content
|
||||
@@ -80,7 +96,7 @@ struct SettingsScreen: View {
|
||||
Button {
|
||||
showBugReport = true
|
||||
} label: {
|
||||
Label(String(localized: "about_bug_report"), systemImage: "ladybug")
|
||||
Label(String(localized: L10n.About.bugReport), systemSymbol: .ladybug)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,30 +140,30 @@ private struct SettingsSectionContent: View {
|
||||
|
||||
func relayModeLabel(_ mode: RelayPreferenceMode) -> String {
|
||||
switch mode {
|
||||
case .automatic: return String(localized: "relay_mode_automatic")
|
||||
case .strictCustom: return String(localized: "relay_mode_custom")
|
||||
case .customWithDirectFallback: return String(localized: "relay_mode_custom_direct_fallback")
|
||||
case .localOnly: return String(localized: "relay_mode_local_only")
|
||||
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 relayModeDescriptionKey(_ mode: RelayPreferenceMode) -> String {
|
||||
func relayModeDescription(_ mode: RelayPreferenceMode) -> String.LocalizationValue {
|
||||
switch mode {
|
||||
case .automatic: return "relay_mode_automatic_description"
|
||||
case .strictCustom: return "relay_mode_custom_description"
|
||||
case .customWithDirectFallback: return "relay_mode_custom_direct_fallback_description"
|
||||
case .localOnly: return "relay_mode_local_only_description"
|
||||
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 {
|
||||
let icon: String
|
||||
let icon: SFSymbol
|
||||
let title: String
|
||||
let value: String?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
Image(systemSymbol: icon)
|
||||
.foregroundStyle(.tint)
|
||||
.frame(width: 26)
|
||||
Text(title).foregroundStyle(.primary)
|
||||
@@ -161,8 +177,8 @@ struct SettingsRow: View {
|
||||
|
||||
func themeModeLabel(_ mode: ThemeMode) -> String {
|
||||
switch mode {
|
||||
case .system: return String(localized: "appearance_system_mode")
|
||||
case .light: return String(localized: "appearance_light_mode")
|
||||
case .dark: return String(localized: "appearance_dark_mode")
|
||||
case .system: return String(localized: L10n.Appearance.systemMode)
|
||||
case .light: return String(localized: L10n.Appearance.lightMode)
|
||||
case .dark: return String(localized: L10n.Appearance.darkMode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
|
||||
/// Settings section detail views, rebuilt as native `Form` content. Each view is
|
||||
/// placed inside a parent `Form`, so it returns `Section`s / rows directly.
|
||||
@@ -7,16 +8,26 @@ struct PreferencesSettings: View {
|
||||
@ObservedObject var model: SettingsModel
|
||||
|
||||
var body: some View {
|
||||
Section(String(localized: "field_username")) {
|
||||
TextField(String(localized: "field_username"),
|
||||
Section(String(localized: L10n.Field.username)) {
|
||||
TextField(String(localized: L10n.Field.username),
|
||||
text: Binding(get: { model.state.username }, set: { model.setUsername($0) }))
|
||||
}
|
||||
if model.state.supportsCustomReceiveFolders {
|
||||
Section(String(localized: "preferences_receive_folder_title")) {
|
||||
Text(model.state.receiveFolder?.displayName ?? String(localized: "value_unavailable"))
|
||||
.foregroundStyle(.secondary)
|
||||
Button(String(localized: "button_choose_folder"), action: model.chooseReceiveFolder)
|
||||
Button(String(localized: "button_reset_default"), action: model.resetReceiveFolder)
|
||||
Section(String(localized: L10n.Preferences.receiveFolderTitle)) {
|
||||
LabeledContent {
|
||||
Button(String(localized: L10n.Button.chooseFolder), action: model.chooseReceiveFolder)
|
||||
} label: {
|
||||
Label {
|
||||
Text(model.state.receiveFolder?.displayName ?? String(localized: L10n.Value.unavailable))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
} icon: {
|
||||
Image(systemSymbol: .folder)
|
||||
}
|
||||
}
|
||||
if !model.isUsingDefaultReceiveFolder {
|
||||
Button(String(localized: L10n.Button.resetDefault), role: .cancel, action: model.resetReceiveFolder)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +38,7 @@ struct AppearanceSettings: View {
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Picker(String(localized: "appearance_title"),
|
||||
Picker(String(localized: L10n.Appearance.title),
|
||||
selection: Binding(get: { model.state.themeMode }, set: { model.setThemeMode($0) })) {
|
||||
ForEach(ThemeMode.allCases, id: \.self) { mode in
|
||||
Text(themeModeLabel(mode)).tag(mode)
|
||||
@@ -44,16 +55,22 @@ struct NotificationSettings: View {
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Toggle(isOn: Binding(
|
||||
get: { model.state.notificationsEnabled },
|
||||
set: { model.setNotificationsEnabled($0) }
|
||||
)) {
|
||||
Text(LocalizedStringKey("notifications_local_title"))
|
||||
}
|
||||
if model.state.notificationPermission == .denied {
|
||||
Button(String(localized: "button_open_settings"), action: model.openNotificationSettings)
|
||||
Text(String(localized: L10n.Notifications.description)).foregroundStyle(.secondary)
|
||||
switch model.state.notificationPermission {
|
||||
case .notDetermined:
|
||||
Button(String(localized: L10n.Notifications.localTitle), action: model.requestNotifications)
|
||||
case .granted:
|
||||
// Allowed — the OS Settings app is where you disable or fine-tune.
|
||||
Text(String(localized: L10n.Notifications.enabledMessage)).foregroundStyle(.secondary)
|
||||
Button(String(localized: L10n.Button.openSettings), action: model.openNotificationSettings)
|
||||
case .denied:
|
||||
Text(String(localized: L10n.Notifications.permissionDenied)).foregroundStyle(.secondary)
|
||||
Button(String(localized: L10n.Button.openSettings), action: model.openNotificationSettings)
|
||||
case .unsupported:
|
||||
Text(String(localized: L10n.Notifications.unsupported)).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.onAppear { model.refreshNotificationPermission() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +80,7 @@ struct NetworkSettings: View {
|
||||
var body: some View {
|
||||
Section {
|
||||
Picker(
|
||||
String(localized: "settings_network_title"),
|
||||
"",
|
||||
selection: Binding(get: { model.state.relayMode }, set: { model.setRelayMode($0) })
|
||||
) {
|
||||
ForEach(RelayPreferenceMode.allCases, id: \.self) { mode in
|
||||
@@ -71,24 +88,27 @@ struct NetworkSettings: View {
|
||||
}
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
.labelsHidden()
|
||||
.disabled(model.state.isApplyingRelayConfiguration)
|
||||
} header: {
|
||||
Text(String(localized: L10n.Settings.networkTitle))
|
||||
} footer: {
|
||||
Text(LocalizedStringKey(relayModeDescriptionKey(model.state.relayMode)))
|
||||
Text(String(localized: relayModeDescription(model.state.relayMode)))
|
||||
}
|
||||
|
||||
Section {
|
||||
Label {
|
||||
Text(LocalizedStringKey("relay_privacy_description"))
|
||||
Text(String(localized: L10n.Relay.privacyDescription))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} icon: {
|
||||
Image(systemName: "lock.shield")
|
||||
Image(systemSymbol: .lockShield)
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let endpointId = model.state.endpointId, !endpointId.isEmpty {
|
||||
Section {
|
||||
Text(String(format: String(localized: "approval_endpoint_id"), endpointId))
|
||||
Text(L10n.Approval.endpointId(deviceId: endpointId))
|
||||
.font(.footnote.monospaced())
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
@@ -98,10 +118,10 @@ struct NetworkSettings: View {
|
||||
Section {
|
||||
if model.state.relayMode == .strictCustom {
|
||||
Label {
|
||||
Text(LocalizedStringKey("relay_strict_warning"))
|
||||
Text(String(localized: L10n.Relay.strictWarning))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} icon: {
|
||||
Image(systemName: "exclamationmark.shield.fill")
|
||||
Image(systemSymbol: .exclamationmarkShieldFill)
|
||||
}
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
@@ -110,7 +130,7 @@ struct NetworkSettings: View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
TextField(
|
||||
"https://relay.example.com",
|
||||
"",
|
||||
text: Binding(
|
||||
get: {
|
||||
model.state.relayURLs.indices.contains(index)
|
||||
@@ -118,8 +138,12 @@ struct NetworkSettings: View {
|
||||
: ""
|
||||
},
|
||||
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)
|
||||
@@ -130,10 +154,10 @@ struct NetworkSettings: View {
|
||||
Button(role: .destructive) {
|
||||
model.removeRelayURL(at: index)
|
||||
} label: {
|
||||
Image(systemName: "minus.circle.fill")
|
||||
Image(systemSymbol: .minusCircleFill)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.accessibilityLabel(Text(LocalizedStringKey("relay_remove_url")))
|
||||
.accessibilityLabel(Text(String(localized: L10n.Relay.removeUrl)))
|
||||
.disabled(model.state.isApplyingRelayConfiguration)
|
||||
}
|
||||
|
||||
@@ -146,16 +170,16 @@ struct NetworkSettings: View {
|
||||
}
|
||||
|
||||
Button(action: model.addRelayURL) {
|
||||
Label(String(localized: "relay_add_url"), systemImage: "plus.circle")
|
||||
Label(String(localized: L10n.Relay.addUrl), systemSymbol: .plusCircle)
|
||||
}
|
||||
.disabled(
|
||||
model.state.relayURLs.count >= RelayConfigurationValidator.maximumRelayCount
|
||||
|| model.state.isApplyingRelayConfiguration
|
||||
)
|
||||
} header: {
|
||||
Text(LocalizedStringKey("relay_custom_urls_label"))
|
||||
Text(String(localized: L10n.Relay.customUrlsLabel))
|
||||
} footer: {
|
||||
Text(LocalizedStringKey("relay_custom_urls_help"))
|
||||
Text(String(localized: L10n.Relay.customUrlsHelp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +188,7 @@ struct NetworkSettings: View {
|
||||
Label {
|
||||
Text(relayValidationMessage(error))
|
||||
} icon: {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
Image(systemSymbol: .exclamationmarkTriangleFill)
|
||||
}
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
@@ -173,13 +197,9 @@ struct NetworkSettings: View {
|
||||
if model.state.hasActiveNetworkWork || model.state.relayApplyErrorKey != nil {
|
||||
Section {
|
||||
Label {
|
||||
Text(LocalizedStringKey(
|
||||
model.state.hasActiveNetworkWork
|
||||
? "relay_apply_active_transfers"
|
||||
: model.state.relayApplyErrorKey ?? "relay_apply_failed"
|
||||
))
|
||||
Text(String(localized: model.state.hasActiveNetworkWork ? L10n.Relay.applyActiveTransfers : (model.state.relayApplyErrorKey ?? L10n.Relay.applyFailed)))
|
||||
} icon: {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
Image(systemSymbol: .exclamationmarkTriangleFill)
|
||||
}
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
@@ -188,9 +208,7 @@ struct NetworkSettings: View {
|
||||
Section {
|
||||
Button(action: model.applyRelayConfiguration) {
|
||||
HStack {
|
||||
Text(LocalizedStringKey(
|
||||
model.state.isApplyingRelayConfiguration ? "relay_applying" : "relay_apply"
|
||||
))
|
||||
Text(String(localized: model.state.isApplyingRelayConfiguration ? L10n.Relay.applying : L10n.Relay.apply))
|
||||
if model.state.isApplyingRelayConfiguration {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
@@ -203,7 +221,7 @@ struct NetworkSettings: View {
|
||||
|| model.state.hasActiveNetworkWork
|
||||
)
|
||||
} footer: {
|
||||
Text(LocalizedStringKey("relay_apply_restart_description"))
|
||||
Text(String(localized: L10n.Relay.applyRestartDescription))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,18 +229,15 @@ struct NetworkSettings: View {
|
||||
private func relayValidationMessage(_ error: RelayConfigurationValidationError) -> String {
|
||||
switch error {
|
||||
case .missingURL:
|
||||
return String(localized: "relay_validation_missing_url")
|
||||
return String(localized: L10n.Relay.validationMissingUrl)
|
||||
case .tooManyURLs:
|
||||
return String(
|
||||
format: String(localized: "relay_validation_too_many_urls"),
|
||||
RelayConfigurationValidator.maximumRelayCount
|
||||
)
|
||||
return L10n.Relay.validationTooManyUrls(maximum: RelayConfigurationValidator.maximumRelayCount)
|
||||
case .httpsRequired(let index):
|
||||
return String(format: String(localized: "relay_validation_https_required"), index + 1)
|
||||
return L10n.Relay.validationHttpsRequired(line: index + 1)
|
||||
case .invalidURL(let index):
|
||||
return String(format: String(localized: "relay_validation_invalid_url"), index + 1)
|
||||
return L10n.Relay.validationInvalidUrl(line: index + 1)
|
||||
case .duplicateURL(let index):
|
||||
return String(format: String(localized: "relay_validation_duplicate_url"), index + 1)
|
||||
return L10n.Relay.validationDuplicateUrl(line: index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,57 +245,131 @@ struct StorageSettings: View {
|
||||
@ObservedObject var model: SettingsModel
|
||||
@State private var showDeleteConfirmation = false
|
||||
|
||||
private var isBusy: Bool {
|
||||
model.state.isCalculatingStorage || model.state.isCleaningStorage || model.state.isDeletingTransfers
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
if let storage = model.state.storage {
|
||||
LabeledContent(String(localized: "storage_received_files"), value: formatBytes(storage.receivedFiles))
|
||||
LabeledContent(String(localized: "storage_transfer_data"), value: formatBytes(storage.transferCache))
|
||||
LabeledContent(String(localized: "storage_app_data"), value: formatBytes(storage.appData))
|
||||
LabeledContent(String(localized: "storage_temporary"), value: formatBytes(storage.temporary))
|
||||
LabeledContent(String(localized: "storage_total")) {
|
||||
Text(formatBytes(storage.total)).fontWeight(.semibold)
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
Text(LocalizedStringKey("storage_calculating")).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
ProgressView()
|
||||
usageContent
|
||||
} header: {
|
||||
HStack {
|
||||
Text(String(localized: L10n.Storage.usageHeader))
|
||||
Spacer()
|
||||
if model.state.isCalculatingStorage {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Button(action: model.loadStorageUsage) {
|
||||
Label(String(localized: L10n.Storage.refresh), systemSymbol: .arrowClockwise)
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(isBusy)
|
||||
.help(String(localized: L10n.Storage.refresh))
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text(LocalizedStringKey("storage_footer"))
|
||||
Text(String(localized: L10n.Storage.footer))
|
||||
}
|
||||
|
||||
// Reclaim reversible junk (temp + trash) — non-destructive to history.
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
Button(action: model.freeUpSpace) {
|
||||
actionLabel(
|
||||
title: L10n.Storage.freeUpSpace,
|
||||
busyTitle: L10n.Storage.cleaning,
|
||||
isBusy: model.state.isCleaningStorage,
|
||||
symbol: .sparkles,
|
||||
tint: .accentColor
|
||||
)
|
||||
}
|
||||
// `.plain` so pressing the row dims the label instead of flipping it to
|
||||
// the white selection-highlight that the default form button style uses.
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isBusy)
|
||||
} footer: {
|
||||
Text(String(localized: L10n.Storage.freeUpSpaceCaption))
|
||||
}
|
||||
|
||||
// Destructive: clears transfer history + cached share content.
|
||||
Section {
|
||||
Button {
|
||||
showDeleteConfirmation = true
|
||||
} label: {
|
||||
HStack {
|
||||
Text(model.state.isDeletingTransfers
|
||||
? String(localized: "storage_deleting")
|
||||
: String(localized: "storage_delete_transfers"))
|
||||
if model.state.isDeletingTransfers {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
actionLabel(
|
||||
title: L10n.Storage.deleteTransfers,
|
||||
busyTitle: L10n.Storage.deleting,
|
||||
isBusy: model.state.isDeletingTransfers,
|
||||
symbol: .trash,
|
||||
tint: .red
|
||||
)
|
||||
}
|
||||
.disabled(model.state.isDeletingTransfers)
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isBusy)
|
||||
} footer: {
|
||||
Text(String(localized: L10n.Storage.deleteTransfersCaption))
|
||||
}
|
||||
.onAppear { model.loadStorageUsage() }
|
||||
.task { model.loadStorageUsage() }
|
||||
.confirmationDialog(
|
||||
Text(LocalizedStringKey("storage_delete_transfers")),
|
||||
Text(String(localized: L10n.Storage.deleteTransfers)),
|
||||
isPresented: $showDeleteConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(String(localized: "storage_delete_transfers"), role: .destructive) {
|
||||
Button(String(localized: L10n.Storage.deleteTransfers), role: .destructive) {
|
||||
model.deleteAllTransfers()
|
||||
}
|
||||
Button(String(localized: "button_cancel"), role: .cancel) {}
|
||||
Button(String(localized: L10n.Button.cancel), role: .cancel) {}
|
||||
} message: {
|
||||
Text(LocalizedStringKey("storage_delete_transfers_description"))
|
||||
Text(String(localized: L10n.Storage.deleteTransfersDescription))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var usageContent: some View {
|
||||
if let storage = model.state.storage {
|
||||
LabeledContent(String(localized: L10n.Storage.receivedFiles), value: formatBytes(storage.receivedFiles))
|
||||
LabeledContent(String(localized: L10n.Storage.transferData), value: formatBytes(storage.transferCache))
|
||||
LabeledContent(String(localized: L10n.Storage.appData), value: formatBytes(storage.appData))
|
||||
LabeledContent(String(localized: L10n.Storage.temporary), value: formatBytes(storage.temporary))
|
||||
LabeledContent(String(localized: L10n.Storage.total)) {
|
||||
Text(formatBytes(storage.total)).fontWeight(.semibold)
|
||||
}
|
||||
} else if model.state.storageLoadFailed {
|
||||
// Genuine failure (core reported an error) — offer a retry.
|
||||
Button(action: model.loadStorageUsage) {
|
||||
Label(String(localized: L10n.Storage.unavailable), systemSymbol: .arrowClockwise)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
// Loading, or waiting for the core to finish starting.
|
||||
HStack {
|
||||
Text(String(localized: L10n.Storage.calculating)).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tinted, full-width button label with a leading symbol and a trailing
|
||||
/// spinner while busy. `.contentShape` keeps the whole row tappable.
|
||||
private func actionLabel(
|
||||
title: String.LocalizationValue,
|
||||
busyTitle: String.LocalizationValue,
|
||||
isBusy: Bool,
|
||||
symbol: SFSymbol,
|
||||
tint: Color
|
||||
) -> some View {
|
||||
HStack {
|
||||
Label(String(localized: isBusy ? busyTitle : title), systemSymbol: symbol)
|
||||
Spacer()
|
||||
if isBusy {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(tint)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
struct AboutSettings: View {
|
||||
@@ -290,40 +379,40 @@ struct AboutSettings: View {
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Text(LocalizedStringKey("about_tagline")).font(.headline)
|
||||
Text(LocalizedStringKey("about_description")).foregroundStyle(.secondary)
|
||||
Text(String(localized: L10n.About.tagline)).font(.headline)
|
||||
Text(String(localized: L10n.About.description)).foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section(String(localized: "about_is_title")) {
|
||||
AboutPoint("about_is_direct", "paperplane")
|
||||
AboutPoint("about_is_no_account", "person.crop.circle.badge.xmark")
|
||||
AboutPoint("about_is_in_control", "checkmark.shield")
|
||||
AboutPoint("about_is_encrypted", "lock")
|
||||
AboutPoint("about_is_open", "chevron.left.forwardslash.chevron.right")
|
||||
Section(String(localized: L10n.About.isTitle)) {
|
||||
AboutPoint(L10n.About.isDirect, .paperplane)
|
||||
AboutPoint(L10n.About.isNoAccount, .personCropCircleBadgeXmark)
|
||||
AboutPoint(L10n.About.isInControl, .checkmarkShield)
|
||||
AboutPoint(L10n.About.isEncrypted, .lock)
|
||||
AboutPoint(L10n.About.isOpen, .chevronLeftForwardslashChevronRight)
|
||||
}
|
||||
|
||||
Section(String(localized: "about_isnt_title")) {
|
||||
AboutPoint("about_isnt_cloud", "icloud.slash")
|
||||
AboutPoint("about_isnt_sync", "arrow.triangle.2.circlepath")
|
||||
AboutPoint("about_isnt_public", "megaphone")
|
||||
Section(String(localized: L10n.About.isntTitle)) {
|
||||
AboutPoint(L10n.About.isntCloud, .icloudSlash)
|
||||
AboutPoint(L10n.About.isntSync, .arrowTriangle2Circlepath)
|
||||
AboutPoint(L10n.About.isntPublic, .megaphone)
|
||||
}
|
||||
|
||||
Section(String(localized: "about_privacy_title")) {
|
||||
AboutPoint("about_privacy_capability", "qrcode")
|
||||
AboutPoint("about_privacy_deny", "hand.raised")
|
||||
AboutPoint("about_privacy_relay", "antenna.radiowaves.left.and.right")
|
||||
AboutPoint("about_privacy_local", "internaldrive")
|
||||
Section(String(localized: L10n.About.privacyTitle)) {
|
||||
AboutPoint(L10n.About.privacyCapability, .qrcode)
|
||||
AboutPoint(L10n.About.privacyDeny, .handRaised)
|
||||
AboutPoint(L10n.About.privacyRelay, .antennaRadiowavesLeftAndRight)
|
||||
AboutPoint(L10n.About.privacyLocal, .internaldrive)
|
||||
}
|
||||
|
||||
Section(String(localized: "about_title")) {
|
||||
LabeledContent(String(localized: "version_title"), value: model.state.appVersion)
|
||||
Section(String(localized: L10n.About.title)) {
|
||||
LabeledContent(String(localized: L10n.Version.title), value: model.state.appVersion)
|
||||
if let device = model.state.deviceInfo {
|
||||
LabeledContent(String(localized: "device_model_title"), value: device.deviceModel ?? "—")
|
||||
LabeledContent(String(localized: "os_version_title"), value: device.operatingSystem)
|
||||
LabeledContent(String(localized: L10n.Device.modelTitle), value: device.deviceModel ?? "—")
|
||||
LabeledContent(String(localized: L10n.Os.versionTitle), value: device.operatingSystem)
|
||||
}
|
||||
LabeledContent(String(localized: "about_license_label"), value: "Apache 2.0")
|
||||
LabeledContent(String(localized: L10n.About.licenseLabel), value: "Apache 2.0")
|
||||
Link(destination: Self.privacyPolicyURL) {
|
||||
Label(String(localized: "about_privacy_policy_label"), systemImage: "hand.raised")
|
||||
Label(String(localized: L10n.About.privacyPolicyLabel), systemSymbol: .handRaised)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,7 +422,7 @@ struct AboutSettings: View {
|
||||
get: { model.state.diagnosticsEnabled },
|
||||
set: { model.setDiagnosticsEnabled($0) }
|
||||
)) {
|
||||
Text(LocalizedStringKey("diagnostics_title"))
|
||||
Text(String(localized: L10n.Diagnostics.title))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -357,13 +446,13 @@ struct BugReportSheet: View {
|
||||
BugReportSettings(model: model, onSubmitted: { dismiss() })
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(Text(LocalizedStringKey("about_bug_report")))
|
||||
.navigationTitle(Text(String(localized: L10n.About.bugReport)))
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "button_cancel")) { dismiss() }
|
||||
Button(String(localized: L10n.Button.cancel)) { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -373,21 +462,21 @@ struct BugReportSheet: View {
|
||||
|
||||
/// A bullet-style informational row with an SF Symbol and wrapping localized text.
|
||||
private struct AboutPoint: View {
|
||||
let key: String
|
||||
let symbol: String
|
||||
let key: String.LocalizationValue
|
||||
let symbol: SFSymbol
|
||||
|
||||
init(_ key: String, _ symbol: String) {
|
||||
init(_ key: String.LocalizationValue, _ symbol: SFSymbol) {
|
||||
self.key = key
|
||||
self.symbol = symbol
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Label {
|
||||
Text(LocalizedStringKey(key))
|
||||
Text(String(localized: key))
|
||||
.font(.subheadline)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} icon: {
|
||||
Image(systemName: symbol).foregroundStyle(.tint)
|
||||
Image(systemSymbol: symbol).foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -397,36 +486,36 @@ struct BugReportSettings: View {
|
||||
var onSubmitted: () -> Void = {}
|
||||
|
||||
var body: some View {
|
||||
Section(String(localized: "bug_report_what_label")) {
|
||||
Section(String(localized: L10n.Bug.reportWhatLabel)) {
|
||||
TextField("", text: Binding(get: { model.state.bugWhatHappened }, set: { model.setBugWhatHappened($0) }),
|
||||
prompt: Text(LocalizedStringKey("bug_report_what_hint")), axis: .vertical)
|
||||
prompt: Text(String(localized: L10n.Bug.reportWhatHint)), axis: .vertical)
|
||||
.lineLimit(3, reservesSpace: true)
|
||||
.labelsHidden()
|
||||
}
|
||||
Section(String(localized: "bug_report_expected_label")) {
|
||||
Section(String(localized: L10n.Bug.reportExpectedLabel)) {
|
||||
TextField("", text: Binding(get: { model.state.bugExpected }, set: { model.setBugExpected($0) }),
|
||||
prompt: Text(LocalizedStringKey("bug_report_expected_hint")), axis: .vertical)
|
||||
prompt: Text(String(localized: L10n.Bug.reportExpectedHint)), axis: .vertical)
|
||||
.lineLimit(3, reservesSpace: true)
|
||||
.labelsHidden()
|
||||
}
|
||||
Section(String(localized: "bug_report_steps_label")) {
|
||||
Section(String(localized: L10n.Bug.reportStepsLabel)) {
|
||||
TextField("", text: Binding(get: { model.state.bugSteps }, set: { model.setBugSteps($0) }),
|
||||
prompt: Text(LocalizedStringKey("bug_report_steps_hint")), axis: .vertical)
|
||||
prompt: Text(String(localized: L10n.Bug.reportStepsHint)), axis: .vertical)
|
||||
.lineLimit(3, reservesSpace: true)
|
||||
.labelsHidden()
|
||||
}
|
||||
Section(String(localized: "bug_report_contact_label")) {
|
||||
Section(String(localized: L10n.Bug.reportContactLabel)) {
|
||||
TextField("", text: Binding(get: { model.state.bugContact }, set: { model.setBugContact($0) }),
|
||||
prompt: Text(LocalizedStringKey("bug_report_contact_hint")))
|
||||
prompt: Text(String(localized: L10n.Bug.reportContactHint)))
|
||||
.labelsHidden()
|
||||
}
|
||||
Section {
|
||||
Toggle(isOn: Binding(get: { model.state.bugIncludeLogs }, set: { model.setBugIncludeLogs($0) })) {
|
||||
Text(LocalizedStringKey("bug_report_include_logs"))
|
||||
Text(String(localized: L10n.Bug.reportIncludeLogs))
|
||||
}
|
||||
Button(action: { model.submitBugReport(onSuccess: onSubmitted) }) {
|
||||
Text(model.state.isSubmittingBugReport
|
||||
? String(localized: "bug_report_submitting") : String(localized: "bug_report_submit"))
|
||||
? String(localized: L10n.Bug.reportSubmitting) : String(localized: L10n.Bug.reportSubmit))
|
||||
}
|
||||
.disabled(model.state.isSubmittingBugReport)
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ private struct IosDeviceInfoProvider: DeviceInfoProvider {
|
||||
device.isBatteryMonitoringEnabled = true
|
||||
defer { device.isBatteryMonitoringEnabled = wasMonitoring }
|
||||
let level = device.batteryLevel
|
||||
return level >= 0 ? "\(Int(level * 100))%" : nil
|
||||
return level >= 0 ? L10n.Battery.levelValue(level: "\(Int(level * 100))") : nil
|
||||
}()
|
||||
return DeviceInfo(
|
||||
deviceName: device.name,
|
||||
|
||||
@@ -32,10 +32,10 @@ struct IosFileSystemService: FileSystemService {
|
||||
|
||||
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
|
||||
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 {
|
||||
return .failure(InvitationError.message("The Files location URL is unavailable"))
|
||||
return .failure(InvitationError.filesystemUnavailable)
|
||||
}
|
||||
let opened = await withCheckedContinuation { continuation in
|
||||
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 {
|
||||
@@ -62,7 +62,7 @@ struct IosFileSystemService: FileSystemService {
|
||||
accessPolicy: ShareAccessPolicy
|
||||
) async -> Result<Share, Error> {
|
||||
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() }
|
||||
return await repository.shareSources(
|
||||
|
||||
@@ -42,8 +42,24 @@ struct MacFileSystemService: FileSystemService {
|
||||
accessPolicy: ShareAccessPolicy
|
||||
) async -> Result<Share, Error> {
|
||||
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 {
|
||||
ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory)
|
||||
}
|
||||
|
||||
@@ -107,9 +107,15 @@ enum PickerSupport {
|
||||
)
|
||||
#else
|
||||
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(
|
||||
value: url.path, displayName: url.lastPathComponent, sizeBytes: size,
|
||||
isTemporaryCopy: false, isDirectory: isDirectory
|
||||
isTemporaryCopy: false, isDirectory: isDirectory, securityScopeBookmark: bookmark
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
|
||||
picker.delegate = self
|
||||
picker.modalPresentationStyle = .formSheet
|
||||
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)
|
||||
}
|
||||
@@ -37,12 +37,12 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
|
||||
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) {
|
||||
cancel()
|
||||
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
|
||||
guard let self else { return }
|
||||
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
|
||||
self.qrController = nil
|
||||
@@ -57,7 +57,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
|
||||
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
|
||||
cancel()
|
||||
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
|
||||
self?.nfcReader = nil
|
||||
@@ -80,7 +80,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
|
||||
let result = documentResult
|
||||
documentResult = nil
|
||||
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()
|
||||
defer { if started { url.stopAccessingSecurityScopedResource() } }
|
||||
let data = try Data(contentsOf: url)
|
||||
@@ -157,19 +157,19 @@ final class QrScannerViewController: UIViewController, AVCaptureMetadataOutputOb
|
||||
}
|
||||
|
||||
func cancelScan() {
|
||||
finish(.failure(InvitationError.message("QR scanning was cancelled")))
|
||||
finish(.failure(InvitationError.cancelled))
|
||||
}
|
||||
|
||||
private func configureSession() {
|
||||
guard let device = AVCaptureDevice.default(for: .video),
|
||||
let input = try? AVCaptureDeviceInput(device: device),
|
||||
session.canAddInput(input) else {
|
||||
return finish(.failure(InvitationError.message("No camera is available")))
|
||||
return finish(.failure(InvitationError.cameraUnavailable))
|
||||
}
|
||||
session.addInput(input)
|
||||
let output = AVCaptureMetadataOutput()
|
||||
guard session.canAddOutput(output) else {
|
||||
return finish(.failure(InvitationError.message("Could not configure the QR scanner")))
|
||||
return finish(.failure(InvitationError.cameraUnavailable))
|
||||
}
|
||||
session.addOutput(output)
|
||||
output.setMetadataObjectsDelegate(self, queue: .main)
|
||||
@@ -220,7 +220,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec
|
||||
|
||||
func start() {
|
||||
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
|
||||
reader.begin()
|
||||
}
|
||||
@@ -233,7 +233,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec
|
||||
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
|
||||
if finished { return }
|
||||
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]) {
|
||||
@@ -242,7 +242,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec
|
||||
.flatMap { $0.records }
|
||||
.compactMap { payloadAsInvitation($0) }
|
||||
.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
|
||||
}
|
||||
session.invalidate()
|
||||
|
||||
@@ -22,7 +22,7 @@ final class MacReceiveInvitationActions: ReceiveInvitationActions {
|
||||
}
|
||||
panel.begin { response in
|
||||
guard response == .OK, let url = panel.url else {
|
||||
onResult(.failure(InvitationError.message("cancelled")))
|
||||
onResult(.failure(InvitationError.cancelled))
|
||||
return
|
||||
}
|
||||
onResult(Result {
|
||||
@@ -33,11 +33,11 @@ final class MacReceiveInvitationActions: ReceiveInvitationActions {
|
||||
}
|
||||
|
||||
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) {
|
||||
onResult(.failure(InvitationError.message("NFC is unavailable on macOS")))
|
||||
onResult(.failure(InvitationError.nfcUnavailable))
|
||||
}
|
||||
|
||||
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) {
|
||||
cancelNfcWrite()
|
||||
guard NFCNDEFReaderSession.readingAvailable else {
|
||||
onResult(.failure(InvitationError.message("NFC is unavailable on this device")))
|
||||
onResult(.failure(InvitationError.nfcUnavailable))
|
||||
return
|
||||
}
|
||||
let writer = InvitationNfcWriter(ticket: ticket) { [weak self] result in
|
||||
@@ -55,7 +55,7 @@ final class IosTransferShareActions: NSObject, TransferShareActions {
|
||||
@MainActor
|
||||
private func present(_ controller: UIViewController) throws {
|
||||
guard let presenter = topPresenter() else {
|
||||
throw InvitationError.message("Could not find an iOS view controller")
|
||||
throw InvitationError.viewControllerUnavailable
|
||||
}
|
||||
presenter.present(controller, animated: true)
|
||||
}
|
||||
@@ -76,7 +76,7 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchec
|
||||
|
||||
func start() {
|
||||
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
|
||||
reader.begin()
|
||||
}
|
||||
@@ -89,14 +89,14 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchec
|
||||
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
|
||||
if finished { return }
|
||||
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, didDetect tags: [NFCNDEFTag]) {
|
||||
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
|
||||
// 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)) }
|
||||
switch status {
|
||||
case .notSupported:
|
||||
self.finish(.failure(InvitationError.message("This NFC tag does not support NDEF")))
|
||||
self.finish(.failure(InvitationError.nfcFailed))
|
||||
case .readOnly:
|
||||
self.finish(.failure(InvitationError.message("This NFC tag is read-only")))
|
||||
self.finish(.failure(InvitationError.nfcFailed))
|
||||
default:
|
||||
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
|
||||
if let writeError {
|
||||
self.finish(.failure(writeError))
|
||||
} else {
|
||||
session.alertMessage = "Invitation written"
|
||||
session.alertMessage = String(localized: L10n.Transfer.nfcWritten)
|
||||
session.invalidate()
|
||||
self.finish(.success(()))
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ final class MacTransferShareActions: TransferShareActions {
|
||||
panel.allowedContentTypes = []
|
||||
panel.begin { response in
|
||||
guard response == .OK, let url = panel.url else {
|
||||
onResult(.failure(InvitationError.message("cancelled")))
|
||||
onResult(.failure(InvitationError.cancelled))
|
||||
return
|
||||
}
|
||||
onResult(Result { try ticket.write(to: url, atomically: true, encoding: .utf8) })
|
||||
@@ -28,7 +28,7 @@ final class MacTransferShareActions: TransferShareActions {
|
||||
do {
|
||||
let url = try writeTemporaryInvitation(ticket: ticket, transferName: transferName)
|
||||
guard let view = NSApp.keyWindow?.contentView else {
|
||||
onResult(.failure(InvitationError.message("No window available")))
|
||||
onResult(.failure(InvitationError.noWindowAvailable))
|
||||
return
|
||||
}
|
||||
let picker = NSSharingServicePicker(items: [url])
|
||||
@@ -40,7 +40,7 @@ final class MacTransferShareActions: TransferShareActions {
|
||||
}
|
||||
|
||||
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() {}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.9685",
|
||||
"green" : "0.3315",
|
||||
"red" : "0.6606"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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,12 @@
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>VniDrop</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<!-- macOS: a single instance only; re-launching activates the running app
|
||||
instead of spawning another copy. -->
|
||||
<key>LSMultipleInstancesProhibited</key>
|
||||
<true/>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -51,6 +57,20 @@
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<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>
|
||||
<true/>
|
||||
<key>NFCReaderUsageDescription</key>
|
||||
@@ -63,10 +83,12 @@
|
||||
<string>VniDrop uses the camera to scan transfer QR codes.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>VniDrop needs local network access to send to other local devices if needed.</string>
|
||||
<!-- iPadOS: a single scene only — no second window via Stage Manager / split
|
||||
view. Mirrors the single-window macOS behavior. -->
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,10 +2,12 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<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>
|
||||
<array>
|
||||
<string>NDEF</string>
|
||||
<string>TAG</string>
|
||||
</array>
|
||||
|
||||
<!-- 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>
|
||||
@@ -41,7 +41,7 @@ private struct SheetChrome<Content: View>: View {
|
||||
ScrollView { content().padding(.top, 4) }
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "button_close"), action: onClose)
|
||||
Button(String(localized: L10n.Button.close), action: onClose)
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
|
||||
@@ -30,7 +30,7 @@ struct StatusPill: View {
|
||||
// MARK: - ProgressRow
|
||||
|
||||
struct ProgressRow: View {
|
||||
let labelKey: String
|
||||
let labelKey: String.LocalizationValue
|
||||
let progress: Double?
|
||||
var detail: String? = nil
|
||||
/// Pre-resolved label; when set it overrides `labelKey`.
|
||||
@@ -42,7 +42,7 @@ struct ProgressRow: View {
|
||||
label.font(.subheadline).lineLimit(1)
|
||||
Spacer()
|
||||
if let progress {
|
||||
Text("\(Int(progress * 100))%").font(.caption).foregroundStyle(.secondary)
|
||||
Text(verbatim: "\(Int(progress * 100))%").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if let detail {
|
||||
@@ -62,7 +62,7 @@ struct ProgressRow: View {
|
||||
if let labelText {
|
||||
Text(labelText)
|
||||
} else {
|
||||
Text(LocalizedStringKey(labelKey))
|
||||
Text(String(localized: labelKey))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
|
||||
/// Bottom toast host driven by `UiMessageController`, ported from
|
||||
/// `ui/feedback/VniDropSnackbarHost.kt`. Tone drives the accent color; errors get
|
||||
@@ -52,7 +53,7 @@ struct SnackbarHost: View {
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
Button(action: dismiss) {
|
||||
Image(systemName: "xmark")
|
||||
Image(systemSymbol: .xmark)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 36, height: 36)
|
||||
|
||||
@@ -4,14 +4,14 @@ import Combine
|
||||
/// A localizable UI string: either a catalog key or dynamic text, ported from
|
||||
/// `UiText` in `ui/feedback/UiMessageController.kt`.
|
||||
enum UiText: Equatable {
|
||||
case resource(String) // Localizable.xcstrings key
|
||||
case resource(String.LocalizationValue) // Localizable.xcstrings key (use L10n.*)
|
||||
case dynamic(String)
|
||||
|
||||
/// Resolves to display text. Keys go through the string catalog.
|
||||
func resolved() -> String {
|
||||
switch self {
|
||||
case .dynamic(let value): return value
|
||||
case .resource(let key): return String(localized: String.LocalizationValue(key))
|
||||
case .resource(let value): return String(localized: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,41 +5,45 @@ import VnidropCore
|
||||
/// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs.
|
||||
extension Error {
|
||||
func toUiText() -> UiText {
|
||||
if let invitation = self as? InvitationError {
|
||||
return invitation.uiText
|
||||
}
|
||||
if let vni = self as? VnidropError {
|
||||
switch vni {
|
||||
case .Ticket:
|
||||
return .resource("error_invalid_ticket")
|
||||
return .resource(L10n.Error.invalidTicket)
|
||||
case .Permission:
|
||||
return .resource("error_permission")
|
||||
return .resource(L10n.Error.permission)
|
||||
case .Filesystem:
|
||||
return .resource("error_filesystem")
|
||||
return .resource(L10n.Error.filesystem)
|
||||
case .FilesystemPermission:
|
||||
return .resource("error_filesystem")
|
||||
return .resource(L10n.Error.filesystem)
|
||||
case .DestinationExists:
|
||||
return .resource("error_destination_exists")
|
||||
return .resource(L10n.Error.destinationExists)
|
||||
case .StorageFull:
|
||||
return .resource("error_storage_full")
|
||||
return .resource(L10n.Error.storageFull)
|
||||
case .Network:
|
||||
return .resource("error_network")
|
||||
return .resource(L10n.Error.network)
|
||||
case .Transfer(let reason):
|
||||
return transferUiText(reason)
|
||||
case .Repository:
|
||||
return .resource("error_repository")
|
||||
return .resource(L10n.Error.repository)
|
||||
case .Cancelled:
|
||||
return .resource("error_generic")
|
||||
return .resource(L10n.Error.generic)
|
||||
case .InvalidInput:
|
||||
return .resource("error_invalid_input")
|
||||
return .resource(L10n.Error.invalidInput)
|
||||
case .Initialization(let reason):
|
||||
return initializationUiText(reason)
|
||||
case .Internal(let reason):
|
||||
return reasonHints(reason) ?? .resource("error_generic")
|
||||
return reasonHints(reason) ?? .resource(L10n.Error.generic)
|
||||
}
|
||||
}
|
||||
return reasonHints(technicalDetail) ?? .resource("error_generic")
|
||||
return reasonHints(technicalDetail) ?? .resource(L10n.Error.generic)
|
||||
}
|
||||
|
||||
/// True when the user intentionally backed out of a flow.
|
||||
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 }
|
||||
let haystack = technicalDetail.lowercased()
|
||||
if haystack.isEmpty {
|
||||
@@ -76,23 +80,89 @@ 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 {
|
||||
let detail = reason.lowercased()
|
||||
if detail.contains("refused") || detail.contains("denied") || detail.contains("not approved") {
|
||||
return .resource("error_permission")
|
||||
return .resource(L10n.Error.permission)
|
||||
}
|
||||
return .resource("error_transfer")
|
||||
return .resource(L10n.Error.transfer)
|
||||
}
|
||||
|
||||
private func initializationUiText(_ reason: String) -> UiText {
|
||||
let detail = reason.lowercased()
|
||||
if detail.contains("native") && detail.contains("library") {
|
||||
return .resource("error_missing_native_library")
|
||||
return .resource(L10n.Error.missingNativeLibrary)
|
||||
}
|
||||
if detail.contains("socket") || detail.contains("bind") {
|
||||
return .resource("error_socket_bind")
|
||||
return .resource(L10n.Error.socketBind)
|
||||
}
|
||||
return .resource("error_initialization")
|
||||
return .resource(L10n.Error.initialization)
|
||||
}
|
||||
|
||||
private func reasonHints(_ detailRaw: String) -> UiText? {
|
||||
@@ -100,50 +170,50 @@ private func reasonHints(_ detailRaw: String) -> UiText? {
|
||||
if detail.isEmpty { return nil }
|
||||
|
||||
if detail.contains("still starting") || detail.contains("starting up") {
|
||||
return .resource("error_starting_up")
|
||||
return .resource(L10n.Error.startingUp)
|
||||
}
|
||||
if detail.contains("empty") && (detail.contains("invitation") || detail.contains("ticket") || detail.contains("qr")) {
|
||||
return .resource("error_invitation_empty")
|
||||
return .resource(L10n.Error.invitationEmpty)
|
||||
}
|
||||
if detail.contains("select at least one") || detail.contains("no files found") {
|
||||
return .resource("error_share_empty")
|
||||
return .resource(L10n.Error.shareEmpty)
|
||||
}
|
||||
if detail.contains("camera") {
|
||||
return .resource("error_camera")
|
||||
return .resource(L10n.Error.camera)
|
||||
}
|
||||
if detail.contains("nfc") || detail.contains("ndef")
|
||||
|| (detail.contains("read-only") && detail.contains("tag"))
|
||||
|| detail.contains("tag is too small") || detail.contains("no nfc tag") {
|
||||
return .resource("error_nfc")
|
||||
return .resource(L10n.Error.nfc)
|
||||
}
|
||||
if detail.contains("native") && detail.contains("library") {
|
||||
return .resource("error_missing_native_library")
|
||||
return .resource(L10n.Error.missingNativeLibrary)
|
||||
}
|
||||
if detail.contains("socket") || detail.contains("bind") {
|
||||
return .resource("error_socket_bind")
|
||||
return .resource(L10n.Error.socketBind)
|
||||
}
|
||||
if detail.contains("device information") || detail.contains("device info") {
|
||||
return .resource("error_device_info")
|
||||
return .resource(L10n.Error.deviceInfo)
|
||||
}
|
||||
if detail.contains("refused") || detail.contains("denied") || detail.contains("permission")
|
||||
|| detail.contains("not approved") || detail.contains("waiting for approval") {
|
||||
return .resource("error_permission")
|
||||
return .resource(L10n.Error.permission)
|
||||
}
|
||||
if detail.contains("invalid ticket") || detail.contains("ticket error")
|
||||
|| detail.contains("could not be read") || detail.contains("malformed")
|
||||
|| detail.contains("invitation could not be opened") {
|
||||
return .resource("error_invalid_ticket")
|
||||
return .resource(L10n.Error.invalidTicket)
|
||||
}
|
||||
if detail.contains("selected")
|
||||
&& (detail.contains("file") || detail.contains("folder") || detail.contains("document") || detail.contains("open")) {
|
||||
return .resource("error_selection_failed")
|
||||
return .resource(L10n.Error.selectionFailed)
|
||||
}
|
||||
if detail.contains("could not open the selected") || detail.contains("could not open selected") {
|
||||
return .resource("error_selection_failed")
|
||||
return .resource(L10n.Error.selectionFailed)
|
||||
}
|
||||
if detail.contains("document picker") || detail.contains("folder picker") || detail.contains("file descriptor")
|
||||
|| detail.contains("view controller") {
|
||||
return .resource("error_selection_failed")
|
||||
return .resource(L10n.Error.selectionFailed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import SFSafeSymbols
|
||||
|
||||
/// Top-level destinations, ported from `ui/navigation/AppDestination.kt`.
|
||||
enum AppDestination: String, CaseIterable, Identifiable {
|
||||
@@ -8,20 +9,20 @@ enum AppDestination: String, CaseIterable, Identifiable {
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var labelKey: String {
|
||||
var labelKey: String.LocalizationValue {
|
||||
switch self {
|
||||
case .send: return "nav_send"
|
||||
case .receive: return "nav_receive"
|
||||
case .settings: return "nav_settings"
|
||||
case .send: return L10n.Nav.send
|
||||
case .receive: return L10n.Nav.receive
|
||||
case .settings: return L10n.Nav.settings
|
||||
}
|
||||
}
|
||||
|
||||
/// SF Symbol approximating the Compose line icon.
|
||||
var systemImage: String {
|
||||
var systemSymbol: SFSymbol {
|
||||
switch self {
|
||||
case .send: return "paperplane"
|
||||
case .receive: return "tray.and.arrow.down"
|
||||
case .settings: return "gearshape"
|
||||
case .send: return .paperplane
|
||||
case .receive: return .trayAndArrowDown
|
||||
case .settings: return .gearshape
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,9 @@ struct VniDropColors {
|
||||
}
|
||||
|
||||
extension VniDropColors {
|
||||
/// The single brand accent used app-wide as the SwiftUI tint.
|
||||
/// The single brand accent used app-wide as the SwiftUI tint. Mirrored by the
|
||||
/// `AccentColor` asset (the OS-level global accent for the macOS sidebar etc.);
|
||||
/// keep the two in sync.
|
||||
static let brandPurple = Color.hsl(271, 91, 65)
|
||||
|
||||
static let light = VniDropColors(
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# XcodeGen spec for the native SwiftUI VniDrop app (iOS/iPadOS/macOS).
|
||||
# Regenerate the project with: xcodegen generate (run from apple/)
|
||||
# Requires the Rust core first: apple/scripts/build-core.sh debug
|
||||
# Requires two generated inputs first (both gitignored), before xcodegen:
|
||||
# - Rust core: apple/scripts/build-core.sh debug
|
||||
# - Localization: (cd localization && bun run src/cli.ts generate)
|
||||
# -> VniDrop/Resources/Localizable.xcstrings, VniDrop/Generated/L10n.swift
|
||||
name: VniDrop
|
||||
options:
|
||||
bundleIdPrefix: com.vnidrop
|
||||
@@ -9,30 +12,67 @@ options:
|
||||
macOS: "15.0"
|
||||
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).
|
||||
settings:
|
||||
base:
|
||||
# Strip unreachable code from release binaries.
|
||||
DEAD_CODE_STRIPPING: YES
|
||||
# Flag user-facing strings that aren't localized (the app ships 9 languages).
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED: YES
|
||||
|
||||
packages:
|
||||
VnidropCore:
|
||||
path: VnidropCore
|
||||
SFSafeSymbols:
|
||||
url: https://github.com/SFSafeSymbols/SFSafeSymbols
|
||||
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:
|
||||
VniDrop:
|
||||
# Shared definition for the two shipping app targets. `VniDrop` (App Store /
|
||||
# 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
|
||||
supportedDestinations: [iOS, macOS]
|
||||
configFiles:
|
||||
Debug: Signing.xcconfig
|
||||
Release: Signing.xcconfig
|
||||
Release-Direct: Signing.xcconfig
|
||||
sources:
|
||||
- path: VniDrop
|
||||
excludes:
|
||||
- "Resources/Info.plist"
|
||||
- "Resources/VniDrop.entitlements"
|
||||
- "Resources/VniDropDirect.entitlements"
|
||||
- "Resources/**/.DS_Store"
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_NAME: VniDrop
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.vnidrop.app
|
||||
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"
|
||||
GENERATE_INFOPLIST_FILE: NO
|
||||
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
|
||||
# (the editor reads these build settings, not the manual plist).
|
||||
INFOPLIST_KEY_CFBundleDisplayName: VniDrop
|
||||
@@ -41,16 +81,82 @@ targets:
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
ENABLE_USER_SCRIPT_SANDBOXING: NO
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
# App-wide accent (macOS sidebar selection, default control tints). The
|
||||
# AccentColor asset mirrors VniDropColors.brandPurple — keep them in sync.
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
|
||||
configs:
|
||||
debug:
|
||||
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
|
||||
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:
|
||||
- package: VnidropCore
|
||||
- package: SFSafeSymbols
|
||||
- sdk: SystemConfiguration.framework
|
||||
- sdk: Security.framework
|
||||
- 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:
|
||||
type: bundle.unit-test
|
||||
@@ -58,6 +164,7 @@ targets:
|
||||
configFiles:
|
||||
Debug: Signing.xcconfig
|
||||
Release: Signing.xcconfig
|
||||
Release-Direct: Signing.xcconfig
|
||||
sources:
|
||||
- path: Tests
|
||||
settings:
|
||||
@@ -79,3 +186,21 @@ schemes:
|
||||
config: Debug
|
||||
targets:
|
||||
- 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,
|
||||
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
|
||||
|
||||
`CoreLimits` controls source count, collection files and bytes, path and ticket
|
||||
|
||||
@@ -2,8 +2,9 @@ use anyhow::Context;
|
||||
use iroh::RelayUrl;
|
||||
use iroh_blobs::Hash;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::BTreeSet, net::IpAddr, str::FromStr};
|
||||
use std::{collections::BTreeSet, net::IpAddr, path::PathBuf, str::FromStr};
|
||||
|
||||
use crate::error::VnidropError;
|
||||
use crate::util::{non_empty, now_ms};
|
||||
|
||||
pub(crate) const MAX_CUSTOM_RELAYS: usize = 8;
|
||||
@@ -126,6 +127,49 @@ 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)]
|
||||
pub struct CoreLimits {
|
||||
pub max_sources: u64,
|
||||
|
||||
@@ -8,7 +8,10 @@ use uuid::Uuid;
|
||||
use crate::{
|
||||
access_policy::{AccessPolicy, APPROVAL_SESSION_TTL_MS},
|
||||
event_hub::EventHub,
|
||||
handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, RequestTransfer},
|
||||
handshake::{
|
||||
DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse,
|
||||
RequestTransfer,
|
||||
},
|
||||
repository::{ReceiverRequestInsert, Repository},
|
||||
transfer_state::ReceiverRequestStatus,
|
||||
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(
|
||||
repository: Repository,
|
||||
event_hub: Arc<EventHub>,
|
||||
|
||||
@@ -72,6 +72,14 @@ impl ProtocolHandler for HandshakeService {
|
||||
.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> {
|
||||
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)]
|
||||
@@ -140,6 +155,14 @@ pub(crate) struct DeliveryReceipt {
|
||||
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)]
|
||||
pub(crate) enum DeliveryReceiptResponse {
|
||||
Recorded,
|
||||
@@ -153,4 +176,6 @@ enum HandshakeProtocol {
|
||||
RequestTransfer(RequestTransfer),
|
||||
#[rpc(tx=oneshot::Sender<DeliveryReceiptResponse>)]
|
||||
ReportDelivery(DeliveryReceipt),
|
||||
#[rpc(tx=oneshot::Sender<DeliveryReceiptResponse>)]
|
||||
ReportDeliveryFailure(DeliveryFailureReceipt),
|
||||
}
|
||||
|
||||
@@ -14,11 +14,11 @@ mod transfer_state;
|
||||
mod util;
|
||||
|
||||
pub use api::{
|
||||
default_core_limits, default_core_network_config, CoreEvent, CoreEventSink, CoreLimits,
|
||||
CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, PublishedOutput, ReceiveOutputSink,
|
||||
ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus,
|
||||
ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer, TicketInspection,
|
||||
TransferAccessMode, TransferMetadata,
|
||||
clear_inactive_transfer_cache, default_core_limits, default_core_network_config, CoreEvent,
|
||||
CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, PublishedOutput,
|
||||
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest,
|
||||
RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer,
|
||||
TicketInspection, TransferAccessMode, TransferMetadata,
|
||||
};
|
||||
pub use error::VnidropError;
|
||||
pub use runtime::VnidropCore;
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::{
|
||||
util::now_ms,
|
||||
};
|
||||
|
||||
const SCHEMA_VERSION: i64 = 6;
|
||||
const SCHEMA_VERSION: i64 = 7;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Repository {
|
||||
@@ -86,6 +86,7 @@ pub(crate) struct PendingDeliveryReceipt {
|
||||
pub(crate) request_id: String,
|
||||
pub(crate) sender_transfer_id: u64,
|
||||
pub(crate) token: String,
|
||||
pub(crate) failure_reason: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct PendingDeliveryReceiptInsert<'a> {
|
||||
@@ -94,6 +95,7 @@ pub(crate) struct PendingDeliveryReceiptInsert<'a> {
|
||||
pub(crate) request_id: &'a str,
|
||||
pub(crate) sender_transfer_id: u64,
|
||||
pub(crate) token: &'a str,
|
||||
pub(crate) failure_reason: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl Repository {
|
||||
@@ -294,12 +296,24 @@ impl Repository {
|
||||
sender_blob_ticket TEXT NOT NULL,
|
||||
sender_transfer_id INTEGER NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
failure_reason TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.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}"))
|
||||
.execute(&self.pool)
|
||||
@@ -561,13 +575,14 @@ impl Repository {
|
||||
r#"
|
||||
INSERT INTO pending_delivery_receipts (
|
||||
request_id, local_transfer_id, sender_blob_ticket,
|
||||
sender_transfer_id, token, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
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
|
||||
token = excluded.token,
|
||||
failure_reason = excluded.failure_reason
|
||||
"#,
|
||||
)
|
||||
.bind(receipt.request_id)
|
||||
@@ -575,6 +590,7 @@ impl Repository {
|
||||
.bind(receipt.sender_blob_ticket)
|
||||
.bind(to_db_id(receipt.sender_transfer_id)?)
|
||||
.bind(receipt.token)
|
||||
.bind(receipt.failure_reason)
|
||||
.bind(now_ms())
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
@@ -582,13 +598,46 @@ impl Repository {
|
||||
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(
|
||||
&self,
|
||||
) -> Result<Vec<PendingDeliveryReceipt>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT local_transfer_id, sender_blob_ticket, request_id,
|
||||
sender_transfer_id, token
|
||||
sender_transfer_id, token, failure_reason
|
||||
FROM pending_delivery_receipts
|
||||
ORDER BY created_at ASC
|
||||
"#,
|
||||
@@ -603,6 +652,7 @@ impl Repository {
|
||||
request_id: row.get("request_id"),
|
||||
sender_transfer_id: row.get::<i64, _>("sender_transfer_id") as u64,
|
||||
token: row.get("token"),
|
||||
failure_reason: row.get("failure_reason"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -883,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> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
|
||||
@@ -4,7 +4,9 @@ use serde_json::json;
|
||||
|
||||
use super::{filter_peer_addr_for_relay_mode, CoreInner};
|
||||
use crate::{
|
||||
handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeService},
|
||||
handshake::{
|
||||
DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeService,
|
||||
},
|
||||
repository::PendingDeliveryReceipt,
|
||||
ticket::parse_persisted_sender_address,
|
||||
};
|
||||
@@ -96,13 +98,29 @@ impl CoreInner {
|
||||
}
|
||||
};
|
||||
let client = HandshakeService::client(self.endpoint.clone(), sender_addr);
|
||||
let receipt = DeliveryReceipt {
|
||||
request_id: pending.request_id.clone(),
|
||||
transfer_id: pending.sender_transfer_id,
|
||||
token: pending.token,
|
||||
};
|
||||
match tokio::time::timeout(DELIVERY_RECEIPT_TIMEOUT, client.report_delivery(receipt)).await
|
||||
{
|
||||
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)) => {
|
||||
if let Err(error) = self
|
||||
.repository
|
||||
|
||||
@@ -16,6 +16,8 @@ mod share;
|
||||
mod storage;
|
||||
|
||||
pub use facade::VnidropCore;
|
||||
#[cfg(test)]
|
||||
pub(crate) use provider::{consume_request_updates, RequestStreamOutcome};
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
|
||||
@@ -10,6 +10,31 @@ use tokio::sync::mpsc;
|
||||
use super::CoreInner;
|
||||
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 {
|
||||
pub(super) async fn spawn_provider_event_task(
|
||||
self: &Arc<Self>,
|
||||
@@ -302,7 +327,7 @@ impl CoreInner {
|
||||
transfer_id: u64,
|
||||
connection_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
|
||||
// shutdown closes those streams; only the long-lived provider receiver
|
||||
@@ -318,35 +343,35 @@ impl CoreInner {
|
||||
.cloned();
|
||||
let core = self.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Ok(Some(update)) = rx.recv().await {
|
||||
match update {
|
||||
RequestUpdate::Started(started) => core.emit_transfer(
|
||||
transfer_id,
|
||||
"send",
|
||||
"transfer",
|
||||
"started",
|
||||
json!({
|
||||
"connection_id": connection_id,
|
||||
"request_id": request_id,
|
||||
"endpoint_id": endpoint_id,
|
||||
"hash": started.hash.to_string(),
|
||||
"size": started.size,
|
||||
"index": started.index,
|
||||
}),
|
||||
),
|
||||
RequestUpdate::Progress(progress) => core.emit_transfer(
|
||||
transfer_id,
|
||||
"send",
|
||||
"transfer",
|
||||
"progress",
|
||||
json!({
|
||||
"connection_id": connection_id,
|
||||
"request_id": request_id,
|
||||
"endpoint_id": endpoint_id,
|
||||
"end_offset": progress.end_offset,
|
||||
}),
|
||||
),
|
||||
RequestUpdate::Completed(_) => core.emit_transfer(
|
||||
let outcome = consume_request_updates(rx, |update| match update {
|
||||
RequestUpdate::Started(started) => core.emit_transfer(
|
||||
transfer_id,
|
||||
"send",
|
||||
"transfer",
|
||||
"started",
|
||||
json!({
|
||||
"connection_id": connection_id,
|
||||
"request_id": request_id,
|
||||
"endpoint_id": endpoint_id,
|
||||
"hash": started.hash.to_string(),
|
||||
"size": started.size,
|
||||
"index": started.index,
|
||||
}),
|
||||
),
|
||||
RequestUpdate::Progress(progress) => core.emit_transfer(
|
||||
transfer_id,
|
||||
"send",
|
||||
"transfer",
|
||||
"progress",
|
||||
json!({
|
||||
"connection_id": connection_id,
|
||||
"request_id": request_id,
|
||||
"endpoint_id": endpoint_id,
|
||||
"end_offset": progress.end_offset,
|
||||
}),
|
||||
),
|
||||
RequestUpdate::Completed(_) => {
|
||||
core.emit_transfer(
|
||||
transfer_id,
|
||||
"send",
|
||||
"transfer",
|
||||
@@ -356,8 +381,10 @@ impl CoreInner {
|
||||
"request_id": request_id,
|
||||
"endpoint_id": endpoint_id,
|
||||
}),
|
||||
),
|
||||
RequestUpdate::Aborted(_) => core.emit_transfer(
|
||||
);
|
||||
}
|
||||
RequestUpdate::Aborted(_) => {
|
||||
core.emit_transfer(
|
||||
transfer_id,
|
||||
"send",
|
||||
"transfer",
|
||||
@@ -367,8 +394,22 @@ impl CoreInner {
|
||||
"request_id": request_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::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
@@ -222,6 +222,10 @@ impl CoreInner {
|
||||
self.persist_receive_start(transfer_id, &parsed, receiver_name.as_deref())
|
||||
.await
|
||||
.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
|
||||
// local state while lower-level Iroh work unwinds naturally.
|
||||
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
|
||||
@@ -237,7 +241,14 @@ impl CoreInner {
|
||||
);
|
||||
|
||||
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)
|
||||
},
|
||||
_ = &mut shutdown_rx => (Err(VnidropError::cancelled("transfer cancelled")), true),
|
||||
@@ -267,6 +278,34 @@ impl CoreInner {
|
||||
)
|
||||
.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)
|
||||
}
|
||||
@@ -277,6 +316,8 @@ impl CoreInner {
|
||||
parsed: ParsedTransferTicket,
|
||||
target: ReceiveTarget,
|
||||
receiver_name: Option<String>,
|
||||
persisted_sender_address: String,
|
||||
pending_delivery_receipt: Arc<Mutex<Option<DeliveryReceipt>>>,
|
||||
) -> Result<()> {
|
||||
if let ReceiveTarget::Directory(output_dir) = &target {
|
||||
tokio::fs::create_dir_all(output_dir)
|
||||
@@ -284,8 +325,6 @@ impl CoreInner {
|
||||
.map_err(VnidropError::filesystem)?;
|
||||
}
|
||||
let sender_addr = parsed.blob_ticket.addr().clone();
|
||||
let persisted_sender_address = encode_persisted_sender_address(&sender_addr)
|
||||
.context("failed to encode sender address for delivery receipt")?;
|
||||
|
||||
self.emit_transfer(transfer_id, "receive", "network", "connecting", json!({}));
|
||||
// Every VniDrop ticket carries metadata and must complete the handshake.
|
||||
@@ -297,6 +336,9 @@ impl CoreInner {
|
||||
receiver_name.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
*pending_delivery_receipt
|
||||
.lock()
|
||||
.expect("pending_delivery_receipt") = Some(delivery_receipt.clone());
|
||||
let connection = self
|
||||
.endpoint
|
||||
.connect(sender_addr.clone(), iroh_blobs::ALPN)
|
||||
@@ -373,9 +415,14 @@ impl CoreInner {
|
||||
request_id: &delivery_receipt.request_id,
|
||||
sender_transfer_id: delivery_receipt.transfer_id,
|
||||
token: &delivery_receipt.token,
|
||||
failure_reason: None,
|
||||
})
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
pending_delivery_receipt
|
||||
.lock()
|
||||
.expect("pending_delivery_receipt")
|
||||
.take();
|
||||
drop(download_tag);
|
||||
self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({}));
|
||||
self.delivery_receipt_notify.notify_one();
|
||||
|
||||
@@ -66,7 +66,7 @@ async fn received_artifacts_survive_history_deletion() {
|
||||
async fn persists_transfers_and_events_across_reopen() {
|
||||
let temp = tempfile::tempdir().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
|
||||
.insert_transfer(transfer(
|
||||
7,
|
||||
@@ -135,6 +135,7 @@ async fn receive_completion_persists_delivery_receipt_until_recorded() {
|
||||
request_id: "request-93",
|
||||
sender_transfer_id: 39,
|
||||
token: "receipt-token",
|
||||
failure_reason: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -221,6 +222,68 @@ async fn receiver_request_can_only_be_resolved_once() {
|
||||
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]
|
||||
async fn startup_expiration_is_idempotent_for_pending_requests() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -582,7 +645,7 @@ async fn migrates_schema_v2_identity_without_losing_transfer() {
|
||||
pool.close().await;
|
||||
|
||||
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);
|
||||
assert_eq!(stored.transfer_id, 7);
|
||||
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::{
|
||||
repository::{PendingDeliveryReceiptInsert, Repository, TransferUpsert},
|
||||
runtime::{consume_request_updates, RequestStreamOutcome},
|
||||
transfer_state::{TransferDirection, TransferStatus},
|
||||
CoreEvent, CoreEventSink, VnidropCore, VnidropError,
|
||||
};
|
||||
@@ -14,6 +21,37 @@ impl CoreEventSink for TestSink {
|
||||
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]
|
||||
fn initializes_and_reports_endpoint() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -126,6 +164,7 @@ fn startup_processes_persisted_delivery_receipts() {
|
||||
request_id: "request-94",
|
||||
sender_transfer_id: 49,
|
||||
token: "receipt-token",
|
||||
failure_reason: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -87,6 +87,7 @@ pub(crate) enum ReceiverRequestStatus {
|
||||
Refused,
|
||||
Expired,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl ReceiverRequestStatus {
|
||||
@@ -97,6 +98,7 @@ impl ReceiverRequestStatus {
|
||||
Self::Refused => "refused",
|
||||
Self::Expired => "expired",
|
||||
Self::Completed => "completed",
|
||||
Self::Failed => "failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,6 +113,7 @@ impl TryFrom<&str> for ReceiverRequestStatus {
|
||||
"refused" => Ok(Self::Refused),
|
||||
"expired" => Ok(Self::Expired),
|
||||
"completed" => Ok(Self::Completed),
|
||||
"failed" => Ok(Self::Failed),
|
||||
_ => 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].status, "completed");
|
||||
assert!(deliveries[0].completed_at.is_some());
|
||||
assert!(
|
||||
sender.sink.events().iter().any(|event| {
|
||||
// The repository commit becomes visible just before the receipt handler
|
||||
// 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.kind == "receiver-completed"
|
||||
&& 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]
|
||||
|
||||
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());
|
||||
}
|
||||
@@ -1,7 +1,52 @@
|
||||
mod support;
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
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]
|
||||
fn transfers_file_between_two_cores() {
|
||||
@@ -50,6 +95,12 @@ fn transfers_file_between_two_cores() {
|
||||
artifacts[0].locator,
|
||||
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();
|
||||
assert_eq!(receiver.core.list_received_artifacts().unwrap(), artifacts);
|
||||
@@ -135,4 +186,11 @@ fn receive_refuses_to_overwrite_existing_destination() {
|
||||
&& event.kind == "failed"
|
||||
&& 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"
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
*/
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import {
|
||||
APPLE_INFO_PLIST,
|
||||
APPLE_L10N_SWIFT,
|
||||
APPLE_XCSTRINGS,
|
||||
kmpValuesDir,
|
||||
KMP_RESOURCES,
|
||||
@@ -14,6 +17,7 @@ import {
|
||||
} from "../config";
|
||||
import { renderAndroid, type ParsedAndroid } from "../lib/android-xml";
|
||||
import { fromCanonical, type Flavor } from "../lib/placeholders";
|
||||
import { renderSwiftAccessors } from "../lib/swift-accessors";
|
||||
import { renderXcstrings, type XcCatalog, type XcEntry } from "../lib/xcstrings";
|
||||
import { targetsOf, type StringEntry, type StringsFile } from "../types";
|
||||
|
||||
@@ -111,6 +115,10 @@ export async function generate() {
|
||||
await Bun.write(APPLE_XCSTRINGS, renderXcstrings(buildXcstrings(doc)));
|
||||
console.log(`Wrote ${APPLE_XCSTRINGS}`);
|
||||
|
||||
await mkdir(dirname(APPLE_L10N_SWIFT), { recursive: true });
|
||||
await Bun.write(APPLE_L10N_SWIFT, renderSwiftAccessors(doc));
|
||||
console.log(`Wrote ${APPLE_L10N_SWIFT}`);
|
||||
|
||||
await syncInfoPlistLocalizations(doc.supportedLanguages);
|
||||
|
||||
for (const lang of doc.supportedLanguages) {
|
||||
|
||||
@@ -18,6 +18,12 @@ export const APPLE_INFO_PLIST = join(
|
||||
"apple/VniDrop/Resources/Info.plist",
|
||||
);
|
||||
|
||||
/** Generated Swift accessors (`L10n`) for compile-time-checked catalog keys. */
|
||||
export const APPLE_L10N_SWIFT = join(
|
||||
REPO_ROOT,
|
||||
"apple/VniDrop/Generated/L10n.swift",
|
||||
);
|
||||
|
||||
/** KMP / Compose Multiplatform resources root; one values[-lang]/strings.xml per language. */
|
||||
export const KMP_RESOURCES = join(
|
||||
REPO_ROOT,
|
||||
|
||||
139
localization/src/lib/swift-accessors.ts
Normal file
139
localization/src/lib/swift-accessors.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Swift accessor generator.
|
||||
*
|
||||
* Emits a compile-time-checked stand-in for each localization key so Apple call
|
||||
* sites stop passing raw string literals. The runtime path is unchanged: plain
|
||||
* keys become `String.LocalizationValue` constants used with `String(localized:)`
|
||||
* exactly as before, and Apple's catalog lookup still does 100% of the work.
|
||||
*
|
||||
* Keys group by their first `_`-delimited segment (`button_…` -> `enum Button`);
|
||||
* the remainder becomes a camelCase member. Plain keys are `static let`
|
||||
* constants; keys with args become `static func` with typed, named parameters
|
||||
* derived from the entry's `args` metadata.
|
||||
*/
|
||||
import { targetsOf, type Arg, type StringEntry, type StringsFile } from "../types";
|
||||
|
||||
const HEADER = `// Generated by localization/ (bun run src/cli.ts generate). Do not edit.
|
||||
// Keys resolve through Apple's String Catalog exactly as a literal would; these
|
||||
// accessors only make the key compile-time-checked. If a runtime language
|
||||
// switcher (live \`.environment(\\.locale)\`) is ever added, switch the plain
|
||||
// \`static let\` constants to computed \`static var\` so the locale is not frozen.
|
||||
import Foundation
|
||||
`;
|
||||
|
||||
/** Swift type for a localization arg. */
|
||||
function swiftType(arg: Arg): string {
|
||||
switch (arg.type) {
|
||||
case "int":
|
||||
return "Int";
|
||||
case "double":
|
||||
return "Double";
|
||||
case "string":
|
||||
return "String";
|
||||
}
|
||||
}
|
||||
|
||||
/** `create_new_transfer` -> `createNewTransfer`. */
|
||||
function camel(segment: string): string {
|
||||
const parts = segment.split("_").filter(Boolean);
|
||||
return parts
|
||||
.map((p, i) => (i === 0 ? p : p.charAt(0).toUpperCase() + p.slice(1)))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** `button` -> `Button`. */
|
||||
function pascal(segment: string): string {
|
||||
const c = camel(segment);
|
||||
return c.charAt(0).toUpperCase() + c.slice(1);
|
||||
}
|
||||
|
||||
/** A key is groupable only when it starts with an identifier-safe segment. */
|
||||
function isGroupableKey(key: string): boolean {
|
||||
return /^[A-Za-z][A-Za-z0-9_]*$/.test(key);
|
||||
}
|
||||
|
||||
/** Collapse whitespace/newlines so a value fits on one doc-comment line. */
|
||||
function oneLine(text: string): string {
|
||||
return text.replace(/\s*\n\s*/g, " ").trim();
|
||||
}
|
||||
|
||||
/** The displayable text for a language (plural shows its `other` form). */
|
||||
function translationText(entry: StringEntry, lang: string): string | undefined {
|
||||
return entry.translations?.[lang] ?? entry.plural?.[lang]?.other;
|
||||
}
|
||||
|
||||
/**
|
||||
* A rich Quick Help block: source-language text as the abstract, then the raw
|
||||
* key, the context note, and every translation. Quick Help renders the Markdown.
|
||||
*/
|
||||
function docComment(key: string, entry: StringEntry, doc: StringsFile): string {
|
||||
const lines: string[] = [];
|
||||
const source = translationText(entry, doc.sourceLanguage);
|
||||
if (source) lines.push(oneLine(source), "");
|
||||
|
||||
lines.push(`Key: \`${key}\``);
|
||||
if (entry.context) lines.push(`Context: ${oneLine(entry.context)}`);
|
||||
lines.push("");
|
||||
|
||||
for (const lang of doc.supportedLanguages) {
|
||||
const value = translationText(entry, lang);
|
||||
if (value !== undefined) lines.push(`- ${lang}: ${oneLine(value)}`);
|
||||
}
|
||||
|
||||
return lines
|
||||
.map((line) => (line ? ` /// ${line}` : " ///"))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function memberFor(
|
||||
key: string,
|
||||
member: string,
|
||||
entry: StringEntry,
|
||||
doc: StringsFile,
|
||||
): string {
|
||||
const comment = docComment(key, entry, doc);
|
||||
const args = entry.args ?? [];
|
||||
|
||||
// Plain key: a #define-style constant. Apple resolves it via String(localized:).
|
||||
if (args.length === 0 && !entry.plural) {
|
||||
return `${comment}\n static let ${member}: String.LocalizationValue = "${key}"`;
|
||||
}
|
||||
|
||||
// Arg'd (or plural) key: a typed, named function that applies the arguments
|
||||
// through the same String(format: String(localized:)) path used before.
|
||||
const params = args.map((a) => `${a.name}: ${swiftType(a)}`).join(", ");
|
||||
const callArgs = args.map((a) => a.name).join(", ");
|
||||
// The positional format string lives in the catalog; look it up by key and
|
||||
// apply the args exactly as the hand-written call sites did.
|
||||
return `${comment}\n static func ${member}(${params}) -> String {\n String(format: String(localized: "${key}"), ${callArgs})\n }`;
|
||||
}
|
||||
|
||||
export function renderSwiftAccessors(doc: StringsFile): string {
|
||||
// group segment -> rendered members
|
||||
const groups = new Map<string, string[]>();
|
||||
|
||||
for (const [key, entry] of Object.entries(doc.strings)) {
|
||||
if (!targetsOf(entry).includes("apple")) continue;
|
||||
if (!isGroupableKey(key)) continue; // skips legacy `%@` literal keys
|
||||
|
||||
const underscore = key.indexOf("_");
|
||||
const groupSeg = underscore === -1 ? key : key.slice(0, underscore);
|
||||
const memberSeg = underscore === -1 ? key : key.slice(underscore + 1);
|
||||
const group = pascal(groupSeg);
|
||||
const member = camel(memberSeg) || camel(groupSeg);
|
||||
|
||||
const list = groups.get(group) ?? [];
|
||||
list.push(memberFor(key, member, entry, doc));
|
||||
groups.set(group, list);
|
||||
}
|
||||
|
||||
const body = [...groups.keys()]
|
||||
.sort()
|
||||
.map((group) => {
|
||||
const members = groups.get(group)!.join("\n");
|
||||
return ` enum ${group} {\n${members}\n }`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return `${HEADER}\nenum L10n {\n${body}\n}\n`;
|
||||
}
|
||||
@@ -13,76 +13,6 @@
|
||||
"ru"
|
||||
],
|
||||
"strings": {
|
||||
"%@": {
|
||||
"context": "Apple format passthrough placeholder (single value). Legacy literal key — rename to a semantic key.",
|
||||
"targets": [
|
||||
"apple"
|
||||
]
|
||||
},
|
||||
"%@ %@ · %@": {
|
||||
"context": "Apple format template composing three values with a middot separator (e.g. metadata rows). Legacy literal key — rename.",
|
||||
"targets": [
|
||||
"apple"
|
||||
],
|
||||
"args": [
|
||||
{
|
||||
"name": "arg1",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "arg2",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "arg3",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"translations": {
|
||||
"en": "{arg1} {arg2} · {arg3}",
|
||||
"fr": "{arg1} {arg2} · {arg3}",
|
||||
"es": "{arg1} {arg2} · {arg3}",
|
||||
"it": "{arg1} {arg2} · {arg3}",
|
||||
"de": "{arg1} {arg2} · {arg3}",
|
||||
"pt": "{arg1} {arg2} · {arg3}",
|
||||
"pl": "{arg1} {arg2} · {arg3}",
|
||||
"nl": "{arg1} {arg2} · {arg3}",
|
||||
"ru": "{arg1} {arg2} · {arg3}"
|
||||
}
|
||||
},
|
||||
"%@ · %@": {
|
||||
"context": "Apple format template composing two values with a middot separator. Legacy literal key — rename.",
|
||||
"targets": [
|
||||
"apple"
|
||||
],
|
||||
"args": [
|
||||
{
|
||||
"name": "arg1",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "arg2",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"translations": {
|
||||
"en": "{arg1} · {arg2}",
|
||||
"fr": "{arg1} · {arg2}",
|
||||
"es": "{arg1} · {arg2}",
|
||||
"it": "{arg1} · {arg2}",
|
||||
"de": "{arg1} · {arg2}",
|
||||
"pt": "{arg1} · {arg2}",
|
||||
"pl": "{arg1} · {arg2}",
|
||||
"nl": "{arg1} · {arg2}",
|
||||
"ru": "{arg1} · {arg2}"
|
||||
}
|
||||
},
|
||||
"%@%%": {
|
||||
"context": "Apple format template appending a percent sign to a value (e.g. battery level). Legacy literal key — rename.",
|
||||
"targets": [
|
||||
"apple"
|
||||
]
|
||||
},
|
||||
"about_bug_report": {
|
||||
"context": "About screen: button/link that opens the bug report form.",
|
||||
"translations": {
|
||||
@@ -391,6 +321,20 @@
|
||||
"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": {
|
||||
"context": "Settings > Appearance: description for the System/auto option.",
|
||||
"translations": {
|
||||
@@ -476,23 +420,23 @@
|
||||
}
|
||||
},
|
||||
"approval_endpoint_id": {
|
||||
"context": "Approval prompt: shows the requesting device's ID. {arg1} = device/endpoint identifier.",
|
||||
"context": "Approval prompt: shows the requesting device's ID. {deviceId} = device/endpoint identifier.",
|
||||
"args": [
|
||||
{
|
||||
"name": "arg1",
|
||||
"name": "deviceId",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"translations": {
|
||||
"en": "Device ID: {arg1}",
|
||||
"fr": "Identifiant de l’appareil : {arg1}",
|
||||
"es": "ID del dispositivo: {arg1}",
|
||||
"it": "ID dispositivo: {arg1}",
|
||||
"de": "Geräte-ID: {arg1}",
|
||||
"pt": "ID do dispositivo: {arg1}",
|
||||
"pl": "Identyfikator urządzenia: {arg1}",
|
||||
"nl": "Apparaat-ID: {arg1}",
|
||||
"ru": "Идентификатор устройства: {arg1}"
|
||||
"en": "Device ID: {deviceId}",
|
||||
"fr": "Identifiant de l’appareil : {deviceId}",
|
||||
"es": "ID del dispositivo: {deviceId}",
|
||||
"it": "ID dispositivo: {deviceId}",
|
||||
"de": "Geräte-ID: {deviceId}",
|
||||
"pt": "ID do dispositivo: {deviceId}",
|
||||
"pl": "Identyfikator urządzenia: {deviceId}",
|
||||
"nl": "Apparaat-ID: {deviceId}",
|
||||
"ru": "Идентификатор устройства: {deviceId}"
|
||||
}
|
||||
},
|
||||
"approval_nearby_device": {
|
||||
@@ -530,27 +474,27 @@
|
||||
}
|
||||
},
|
||||
"approval_request_body": {
|
||||
"context": "Approval prompt body: '{arg1} wants to receive \"{arg2}\".' {arg1} = requester name, {arg2} = transfer name.",
|
||||
"context": "Approval prompt body: '{receiver} wants to receive \"{transferName}\".' {receiver} = requester name, {transferName} = transfer name.",
|
||||
"args": [
|
||||
{
|
||||
"name": "arg1",
|
||||
"name": "receiver",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "arg2",
|
||||
"name": "transferName",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"translations": {
|
||||
"en": "{arg1} wants to receive “{arg2}”.",
|
||||
"fr": "{arg1} souhaite recevoir « {arg2} ».",
|
||||
"es": "{arg1} quiere recibir «{arg2}».",
|
||||
"it": "{arg1} vuole ricevere «{arg2}».",
|
||||
"de": "{arg1} möchte „{arg2}“ empfangen.",
|
||||
"pt": "{arg1} quer receber «{arg2}».",
|
||||
"pl": "{arg1} chce odebrać „{arg2}”.",
|
||||
"nl": "{arg1} wil ‘{arg2}’ ontvangen.",
|
||||
"ru": "{arg1} хочет получить «{arg2}»."
|
||||
"en": "{receiver} wants to receive “{transferName}”.",
|
||||
"fr": "{receiver} souhaite recevoir « {transferName} ».",
|
||||
"es": "{receiver} quiere recibir «{transferName}».",
|
||||
"it": "{receiver} vuole ricevere «{transferName}».",
|
||||
"de": "{receiver} möchte „{transferName}“ empfangen.",
|
||||
"pt": "{receiver} quer receber «{transferName}».",
|
||||
"pl": "{receiver} chce odebrać „{transferName}”.",
|
||||
"nl": "{receiver} wil ‘{transferName}’ ontvangen.",
|
||||
"ru": "{receiver} хочет получить «{transferName}»."
|
||||
}
|
||||
},
|
||||
"battery_level_title": {
|
||||
@@ -567,6 +511,29 @@
|
||||
"ru": "Уровень заряда"
|
||||
}
|
||||
},
|
||||
"battery_level_value": {
|
||||
"context": "Device information: battery charge formatted as a percentage. {level} = integer percent value.",
|
||||
"targets": [
|
||||
"apple"
|
||||
],
|
||||
"args": [
|
||||
{
|
||||
"name": "level",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"translations": {
|
||||
"en": "{level}%",
|
||||
"fr": "{level} %",
|
||||
"es": "{level} %",
|
||||
"it": "{level}%",
|
||||
"de": "{level} %",
|
||||
"pt": "{level}%",
|
||||
"pl": "{level}%",
|
||||
"nl": "{level}%",
|
||||
"ru": "{level} %"
|
||||
}
|
||||
},
|
||||
"bug_report_contact_hint": {
|
||||
"context": "Bug report form: placeholder text in the contact email field.",
|
||||
"translations": {
|
||||
@@ -861,6 +828,20 @@
|
||||
"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": {
|
||||
"context": "Button: cancel the current action or dialog.",
|
||||
"translations": {
|
||||
@@ -1645,6 +1626,64 @@
|
||||
"ru": "Готово"
|
||||
}
|
||||
},
|
||||
"format_separated_pair": {
|
||||
"context": "Composes two already-localized values with a middot separator (e.g. size · status). {first} and {second} are the two values.",
|
||||
"targets": [
|
||||
"apple"
|
||||
],
|
||||
"args": [
|
||||
{
|
||||
"name": "first",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "second",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"translations": {
|
||||
"en": "{first} · {second}",
|
||||
"fr": "{first} · {second}",
|
||||
"es": "{first} · {second}",
|
||||
"it": "{first} · {second}",
|
||||
"de": "{first} · {second}",
|
||||
"pt": "{first} · {second}",
|
||||
"pl": "{first} · {second}",
|
||||
"nl": "{first} · {second}",
|
||||
"ru": "{first} · {second}"
|
||||
}
|
||||
},
|
||||
"format_separated_triple": {
|
||||
"context": "Composes three already-localized values, the first two space-joined then a middot before the third (e.g. count files · size). {first}, {second}, {third} are the values.",
|
||||
"targets": [
|
||||
"apple"
|
||||
],
|
||||
"args": [
|
||||
{
|
||||
"name": "first",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "second",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "third",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"translations": {
|
||||
"en": "{first} {second} · {third}",
|
||||
"fr": "{first} {second} · {third}",
|
||||
"es": "{first} {second} · {third}",
|
||||
"it": "{first} {second} · {third}",
|
||||
"de": "{first} {second} · {third}",
|
||||
"pt": "{first} {second} · {third}",
|
||||
"pl": "{first} {second} · {third}",
|
||||
"nl": "{first} {second} · {third}",
|
||||
"ru": "{first} {second} · {third}"
|
||||
}
|
||||
},
|
||||
"metadata_files": {
|
||||
"context": "Transfer metadata label: number of files.",
|
||||
"translations": {
|
||||
@@ -1746,15 +1785,15 @@
|
||||
"notifications_description": {
|
||||
"context": "Settings > Notifications: explanation of what notifications are used for.",
|
||||
"translations": {
|
||||
"en": "Get notified about new receive requests while VniDrop is in the background.",
|
||||
"fr": "Soyez averti des nouvelles demandes de réception lorsque VniDrop est en arrière-plan.",
|
||||
"es": "Reciba avisos sobre nuevas solicitudes de recepción cuando VniDrop está en segundo plano.",
|
||||
"it": "Ricevi avvisi sulle nuove richieste di ricezione quando VniDrop è in background.",
|
||||
"de": "Werden Sie über neue Empfangsanfragen benachrichtigt, während VniDrop im Hintergrund läuft.",
|
||||
"pt": "Seja notificado sobre novos pedidos de receção enquanto o VniDrop está em segundo plano.",
|
||||
"pl": "Otrzymuj powiadomienia o nowych prośbach o odbiór, gdy VniDrop działa w tle.",
|
||||
"nl": "Ontvang meldingen over nieuwe ontvangstverzoeken terwijl VniDrop op de achtergrond draait.",
|
||||
"ru": "Получайте уведомления о новых запросах на получение, пока VniDrop работает в фоне."
|
||||
"en": "Get notified about transfer activity while VniDrop is in the background.",
|
||||
"fr": "Soyez averti de l’activité des transferts lorsque VniDrop est en arrière-plan.",
|
||||
"es": "Reciba avisos sobre la actividad de las transferencias cuando VniDrop está en segundo plano.",
|
||||
"it": "Ricevi avvisi sull’attività dei trasferimenti quando VniDrop è in background.",
|
||||
"de": "Werden Sie über Übertragungsaktivitäten benachrichtigt, während VniDrop im Hintergrund läuft.",
|
||||
"pt": "Seja notificado sobre a atividade das transferências enquanto o VniDrop está em segundo plano.",
|
||||
"pl": "Otrzymuj powiadomienia o aktywności transferów, gdy VniDrop działa w tle.",
|
||||
"nl": "Ontvang meldingen over overdrachtsactiviteit terwijl VniDrop op de achtergrond draait.",
|
||||
"ru": "Получайте уведомления об активности передач, пока VniDrop работает в фоне."
|
||||
}
|
||||
},
|
||||
"notifications_enabled_message": {
|
||||
@@ -1799,6 +1838,184 @@
|
||||
"ru": "Уведомления отключены для VniDrop. Вы можете включить их в Настройках."
|
||||
}
|
||||
},
|
||||
"notifications_receive_completed_body": {
|
||||
"context": "Notification body shown when an incoming transfer finishes downloading. {transferName} = transfer name.",
|
||||
"args": [
|
||||
{
|
||||
"name": "transferName",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"translations": {
|
||||
"en": "“{transferName}” finished downloading.",
|
||||
"fr": "« {transferName} » a fini de se télécharger.",
|
||||
"es": "«{transferName}» terminó de descargarse.",
|
||||
"it": "«{transferName}» è stato scaricato.",
|
||||
"de": "„{transferName}“ wurde vollständig heruntergeladen.",
|
||||
"pt": "«{transferName}» concluiu a transferência.",
|
||||
"pl": "Zakończono pobieranie „{transferName}”.",
|
||||
"nl": "‘{transferName}’ is volledig gedownload.",
|
||||
"ru": "«{transferName}» завершил загрузку."
|
||||
}
|
||||
},
|
||||
"notifications_receive_completed_title": {
|
||||
"context": "Notification title shown when an incoming transfer finishes downloading.",
|
||||
"translations": {
|
||||
"en": "Download complete",
|
||||
"fr": "Téléchargement terminé",
|
||||
"es": "Descarga completada",
|
||||
"it": "Download completato",
|
||||
"de": "Download abgeschlossen",
|
||||
"pt": "Transferência concluída",
|
||||
"pl": "Pobieranie zakończone",
|
||||
"nl": "Download voltooid",
|
||||
"ru": "Загрузка завершена"
|
||||
}
|
||||
},
|
||||
"notifications_receive_failed_body": {
|
||||
"context": "Notification body shown when an incoming transfer fails. {transferName} = transfer name.",
|
||||
"args": [
|
||||
{
|
||||
"name": "transferName",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"translations": {
|
||||
"en": "“{transferName}” couldn’t be received.",
|
||||
"fr": "« {transferName} » n’a pas pu être reçu.",
|
||||
"es": "No se pudo recibir «{transferName}».",
|
||||
"it": "Impossibile ricevere «{transferName}».",
|
||||
"de": "„{transferName}“ konnte nicht empfangen werden.",
|
||||
"pt": "Não foi possível receber «{transferName}».",
|
||||
"pl": "Nie udało się odebrać „{transferName}”.",
|
||||
"nl": "‘{transferName}’ kon niet worden ontvangen.",
|
||||
"ru": "Не удалось получить «{transferName}»."
|
||||
}
|
||||
},
|
||||
"notifications_receive_failed_title": {
|
||||
"context": "Notification title shown when an incoming transfer fails.",
|
||||
"translations": {
|
||||
"en": "Download failed",
|
||||
"fr": "Échec du téléchargement",
|
||||
"es": "Error en la descarga",
|
||||
"it": "Download non riuscito",
|
||||
"de": "Download fehlgeschlagen",
|
||||
"pt": "Falha na transferência",
|
||||
"pl": "Pobieranie nie powiodło się",
|
||||
"nl": "Download mislukt",
|
||||
"ru": "Ошибка загрузки"
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"args": [
|
||||
{
|
||||
"name": "receiver",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "transferName",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"translations": {
|
||||
"en": "{receiver} finished receiving “{transferName}”.",
|
||||
"fr": "{receiver} a fini de recevoir « {transferName} ».",
|
||||
"es": "{receiver} terminó de recibir «{transferName}».",
|
||||
"it": "{receiver} ha finito di ricevere «{transferName}».",
|
||||
"de": "{receiver} hat „{transferName}“ vollständig empfangen.",
|
||||
"pt": "{receiver} terminou de receber «{transferName}».",
|
||||
"pl": "{receiver} zakończył odbieranie „{transferName}”.",
|
||||
"nl": "{receiver} heeft ‘{transferName}’ volledig ontvangen.",
|
||||
"ru": "{receiver} завершил получение «{transferName}»."
|
||||
}
|
||||
},
|
||||
"notifications_receiver_completed_title": {
|
||||
"context": "Notification title shown to the sender when a receiver finishes downloading a shared transfer.",
|
||||
"translations": {
|
||||
"en": "Transfer received",
|
||||
"fr": "Transfert reçu",
|
||||
"es": "Transferencia recibida",
|
||||
"it": "Trasferimento ricevuto",
|
||||
"de": "Übertragung empfangen",
|
||||
"pt": "Transferência recebida",
|
||||
"pl": "Transfer odebrany",
|
||||
"nl": "Overdracht ontvangen",
|
||||
"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": {
|
||||
"context": "Notification body shown to the sender when a shared transfer fails. {transferName} = transfer name.",
|
||||
"args": [
|
||||
{
|
||||
"name": "transferName",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"translations": {
|
||||
"en": "“{transferName}” couldn’t be shared.",
|
||||
"fr": "« {transferName} » n’a pas pu être partagé.",
|
||||
"es": "No se pudo compartir «{transferName}».",
|
||||
"it": "Impossibile condividere «{transferName}».",
|
||||
"de": "„{transferName}“ konnte nicht geteilt werden.",
|
||||
"pt": "Não foi possível partilhar «{transferName}».",
|
||||
"pl": "Nie udało się udostępnić „{transferName}”.",
|
||||
"nl": "‘{transferName}’ kon niet worden gedeeld.",
|
||||
"ru": "Не удалось поделиться «{transferName}»."
|
||||
}
|
||||
},
|
||||
"notifications_send_failed_title": {
|
||||
"context": "Notification title shown to the sender when a shared transfer fails.",
|
||||
"translations": {
|
||||
"en": "Sharing failed",
|
||||
"fr": "Échec du partage",
|
||||
"es": "Error al compartir",
|
||||
"it": "Condivisione non riuscita",
|
||||
"de": "Freigabe fehlgeschlagen",
|
||||
"pt": "Falha na partilha",
|
||||
"pl": "Udostępnianie nie powiodło się",
|
||||
"nl": "Delen mislukt",
|
||||
"ru": "Не удалось поделиться"
|
||||
}
|
||||
},
|
||||
"notifications_settings_open_failed": {
|
||||
"context": "Settings > Notifications: error when the OS notification settings can't be opened.",
|
||||
"translations": {
|
||||
@@ -2212,23 +2429,23 @@
|
||||
}
|
||||
},
|
||||
"receive_delete_history_description": {
|
||||
"context": "Receive history: confirmation body for removing one item. {arg1} = transfer name.",
|
||||
"context": "Receive history: confirmation body for removing one item. {transferName} = transfer name.",
|
||||
"args": [
|
||||
{
|
||||
"name": "arg1",
|
||||
"name": "transferName",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"translations": {
|
||||
"en": "“{arg1}” will be removed from VniDrop’s history. The downloaded file will remain on this device.",
|
||||
"fr": "« {arg1} » sera retiré de l’historique de VniDrop. Le fichier téléchargé restera sur cet appareil.",
|
||||
"es": "«{arg1}» se eliminará del historial de VniDrop. El archivo descargado permanecerá en este dispositivo.",
|
||||
"it": "«{arg1}» verrà rimosso dalla cronologia di VniDrop. Il file scaricato rimarrà su questo dispositivo.",
|
||||
"de": "„{arg1}“ wird aus dem Verlauf von VniDrop entfernt. Die heruntergeladene Datei verbleibt auf diesem Gerät.",
|
||||
"pt": "«{arg1}» será removido do histórico do VniDrop. O ficheiro descarregado permanecerá neste dispositivo.",
|
||||
"pl": "„{arg1}” zostanie usunięty z historii VniDrop. Pobrany plik pozostanie na tym urządzeniu.",
|
||||
"nl": "‘{arg1}’ wordt uit de geschiedenis van VniDrop verwijderd. Het gedownloade bestand blijft op dit apparaat.",
|
||||
"ru": "«{arg1}» будет удалён из истории VniDrop. Загруженный файл останется на этом устройстве."
|
||||
"en": "“{transferName}” will be removed from VniDrop’s history. The downloaded file will remain on this device.",
|
||||
"fr": "« {transferName} » sera retiré de l’historique de VniDrop. Le fichier téléchargé restera sur cet appareil.",
|
||||
"es": "«{transferName}» se eliminará del historial de VniDrop. El archivo descargado permanecerá en este dispositivo.",
|
||||
"it": "«{transferName}» verrà rimosso dalla cronologia di VniDrop. Il file scaricato rimarrà su questo dispositivo.",
|
||||
"de": "„{transferName}“ wird aus dem Verlauf von VniDrop entfernt. Die heruntergeladene Datei verbleibt auf diesem Gerät.",
|
||||
"pt": "«{transferName}» será removido do histórico do VniDrop. O ficheiro descarregado permanecerá neste dispositivo.",
|
||||
"pl": "„{transferName}” zostanie usunięty z historii VniDrop. Pobrany plik pozostanie na tym urządzeniu.",
|
||||
"nl": "‘{transferName}’ wordt uit de geschiedenis van VniDrop verwijderd. Het gedownloade bestand blijft op dit apparaat.",
|
||||
"ru": "«{transferName}» будет удалён из истории VniDrop. Загруженный файл останется на этом устройстве."
|
||||
}
|
||||
},
|
||||
"receive_delete_history_item": {
|
||||
@@ -3213,6 +3430,40 @@
|
||||
"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": {
|
||||
@@ -3381,6 +3632,206 @@
|
||||
"ru": "Вычисление…"
|
||||
}
|
||||
},
|
||||
"storage_cleaning": {
|
||||
"context": "Settings > Storage: free-up-space button while cleanup runs.",
|
||||
"translations": {
|
||||
"en": "Cleaning up…",
|
||||
"fr": "Nettoyage…",
|
||||
"es": "Limpiando…",
|
||||
"it": "Pulizia…",
|
||||
"de": "Wird bereinigt…",
|
||||
"pt": "A limpar…",
|
||||
"pl": "Czyszczenie…",
|
||||
"nl": "Opschonen…",
|
||||
"ru": "Очистка…"
|
||||
}
|
||||
},
|
||||
"storage_cleanup_busy": {
|
||||
"context": "Settings > Storage: shown when cleanup is blocked by in-flight transfers.",
|
||||
"translations": {
|
||||
"en": "Finish active transfers before freeing up space",
|
||||
"fr": "Terminez les transferts en cours avant de libérer de l'espace",
|
||||
"es": "Finaliza las transferencias activas antes de liberar espacio",
|
||||
"it": "Completa i trasferimenti attivi prima di liberare spazio",
|
||||
"de": "Beende aktive Übertragungen, bevor du Speicher freigibst",
|
||||
"pt": "Conclui as transferências ativas antes de libertar espaço",
|
||||
"pl": "Zakończ aktywne transfery przed zwolnieniem miejsca",
|
||||
"nl": "Voltooi actieve overdrachten voordat je ruimte vrijmaakt",
|
||||
"ru": "Завершите активные передачи перед освобождением места"
|
||||
}
|
||||
},
|
||||
"storage_cleanup_freed": {
|
||||
"context": "Settings > Storage: cleanup success. {size} = amount freed.",
|
||||
"args": [
|
||||
{
|
||||
"name": "size",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"translations": {
|
||||
"en": "Freed {size}",
|
||||
"fr": "{size} libéré",
|
||||
"es": "Se liberó {size}",
|
||||
"it": "Liberati {size}",
|
||||
"de": "{size} freigegeben",
|
||||
"pt": "Libertado {size}",
|
||||
"pl": "Zwolniono {size}",
|
||||
"nl": "{size} vrijgemaakt",
|
||||
"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": {
|
||||
"context": "Settings > Storage: caption under the destructive delete-all button.",
|
||||
"translations": {
|
||||
"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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"nl": "Wist je verzend- en ontvangstgeschiedenis en de gecachte deelinhoud van de app. Ontvangen bestanden op schijf blijven behouden.",
|
||||
"ru": "Очищает историю отправки и получения и кэшированное содержимое общих ресурсов. Полученные файлы на диске сохраняются."
|
||||
}
|
||||
},
|
||||
"storage_free_up_space_caption": {
|
||||
"context": "Settings > Storage: caption under the free-up-space button.",
|
||||
"translations": {
|
||||
"en": "Removes temporary files and leftover trash from earlier transfers. Your transfers and received files are kept.",
|
||||
"fr": "Supprime les fichiers temporaires et les résidus des transferts précédents. Vos transferts et fichiers reçus sont conservés.",
|
||||
"es": "Elimina los archivos temporales y los restos de transferencias anteriores. Tus transferencias y archivos recibidos se conservan.",
|
||||
"it": "Rimuove i file temporanei e i residui dei trasferimenti precedenti. I tuoi trasferimenti e i file ricevuti vengono mantenuti.",
|
||||
"de": "Entfernt temporäre Dateien und Reste früherer Übertragungen. Deine Übertragungen und empfangenen Dateien bleiben erhalten.",
|
||||
"pt": "Remove ficheiros temporários e resíduos de transferências anteriores. As tuas transferências e ficheiros recebidos são mantidos.",
|
||||
"pl": "Usuwa pliki tymczasowe i pozostałości po wcześniejszych transferach. Twoje transfery i odebrane pliki zostają zachowane.",
|
||||
"nl": "Verwijdert tijdelijke bestanden en resten van eerdere overdrachten. Je overdrachten en ontvangen bestanden blijven behouden.",
|
||||
"ru": "Удаляет временные файлы и остатки прошлых передач. Ваши передачи и полученные файлы сохраняются."
|
||||
}
|
||||
},
|
||||
"storage_refresh": {
|
||||
"context": "Settings > Storage: label for the button that recalculates usage.",
|
||||
"translations": {
|
||||
"en": "Refresh",
|
||||
"fr": "Actualiser",
|
||||
"es": "Actualizar",
|
||||
"it": "Aggiorna",
|
||||
"de": "Aktualisieren",
|
||||
"pt": "Atualizar",
|
||||
"pl": "Odśwież",
|
||||
"nl": "Vernieuwen",
|
||||
"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": {
|
||||
"context": "Settings > Storage: shown when usage couldn't be calculated yet.",
|
||||
"translations": {
|
||||
"en": "Storage usage isn't available yet",
|
||||
"fr": "L'utilisation du stockage n'est pas encore disponible",
|
||||
"es": "El uso de almacenamiento aún no está disponible",
|
||||
"it": "L'utilizzo dello spazio non è ancora disponibile",
|
||||
"de": "Die Speichernutzung ist noch nicht verfügbar",
|
||||
"pt": "A utilização do armazenamento ainda não está disponível",
|
||||
"pl": "Wykorzystanie pamięci nie jest jeszcze dostępne",
|
||||
"nl": "Opslaggebruik is nog niet beschikbaar",
|
||||
"ru": "Данные об использовании хранилища пока недоступны"
|
||||
}
|
||||
},
|
||||
"storage_usage_header": {
|
||||
"context": "Settings > Storage: header above the usage breakdown.",
|
||||
"translations": {
|
||||
"en": "On this device",
|
||||
"fr": "Sur cet appareil",
|
||||
"es": "En este dispositivo",
|
||||
"it": "Su questo dispositivo",
|
||||
"de": "Auf diesem Gerät",
|
||||
"pt": "Neste dispositivo",
|
||||
"pl": "Na tym urządzeniu",
|
||||
"nl": "Op dit apparaat",
|
||||
"ru": "На этом устройстве"
|
||||
}
|
||||
},
|
||||
"storage_free_up_space": {
|
||||
"context": "Settings > Storage: button that clears temporary files and stray trash.",
|
||||
"translations": {
|
||||
"en": "Free up space",
|
||||
"fr": "Libérer de l'espace",
|
||||
"es": "Liberar espacio",
|
||||
"it": "Libera spazio",
|
||||
"de": "Speicher freigeben",
|
||||
"pt": "Libertar espaço",
|
||||
"pl": "Zwolnij miejsce",
|
||||
"nl": "Ruimte vrijmaken",
|
||||
"ru": "Освободить место"
|
||||
}
|
||||
},
|
||||
"storage_delete_transfers": {
|
||||
"context": "Settings > Storage: button to delete all transfer records.",
|
||||
"translations": {
|
||||
@@ -3398,15 +3849,15 @@
|
||||
"storage_delete_transfers_description": {
|
||||
"context": "Settings > Storage: confirmation body for deleting all transfer records.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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ąć.",
|
||||
"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.",
|
||||
"ru": "Это удалит из истории все записи об отправленных и полученных передачах. Полученные файлы не удаляются. Кэшированное общее содержимое, которое больше не требуется, освобождается автоматически; это может занять некоторое время. Это действие нельзя отменить."
|
||||
"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 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 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": "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 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 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": "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 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": "Это удалит из истории все отправленные и полученные передачи и немедленно освободит неиспользуемый кэш. Текущие передачи и полученные файлы не удаляются. Это действие нельзя отменить."
|
||||
}
|
||||
},
|
||||
"storage_app_data": {
|
||||
@@ -3564,23 +4015,23 @@
|
||||
}
|
||||
},
|
||||
"transfer_delete_description": {
|
||||
"context": "Transfer details: confirmation body for deleting a transfer. {arg1} = transfer name.",
|
||||
"context": "Transfer details: confirmation body for deleting a transfer. {transferName} = transfer name.",
|
||||
"args": [
|
||||
{
|
||||
"name": "arg1",
|
||||
"name": "transferName",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"translations": {
|
||||
"en": "“{arg1}” will stop being shared and its transfer history will be removed from this device.",
|
||||
"fr": "« {arg1} » cessera d’être partagé et son historique de transfert sera retiré de cet appareil.",
|
||||
"es": "«{arg1}» dejará de compartirse y su historial de transferencia se eliminará de este dispositivo.",
|
||||
"it": "«{arg1}» non verrà più condiviso e la sua cronologia di trasferimento verrà rimossa da questo dispositivo.",
|
||||
"de": "„{arg1}“ wird nicht mehr geteilt und sein Übertragungsverlauf wird von diesem Gerät entfernt.",
|
||||
"pt": "«{arg1}» deixará de ser partilhado e o seu histórico de transferência será removido deste dispositivo.",
|
||||
"pl": "„{arg1}” przestanie być udostępniany, a jego historia transferu zostanie usunięta z tego urządzenia.",
|
||||
"nl": "‘{arg1}’ wordt niet meer gedeeld en de overdrachtsgeschiedenis wordt van dit apparaat verwijderd.",
|
||||
"ru": "Общий доступ к «{arg1}» будет остановлен, а история передачи будет удалена с этого устройства."
|
||||
"en": "“{transferName}” will stop being shared and its transfer history will be removed from this device.",
|
||||
"fr": "« {transferName} » cessera d’être partagé et son historique de transfert sera retiré de cet appareil.",
|
||||
"es": "«{transferName}» dejará de compartirse y su historial de transferencia se eliminará de este dispositivo.",
|
||||
"it": "«{transferName}» non verrà più condiviso e la sua cronologia di trasferimento verrà rimossa da questo dispositivo.",
|
||||
"de": "„{transferName}“ wird nicht mehr geteilt und sein Übertragungsverlauf wird von diesem Gerät entfernt.",
|
||||
"pt": "«{transferName}» deixará de ser partilhado e o seu histórico de transferência será removido deste dispositivo.",
|
||||
"pl": "„{transferName}” przestanie być udostępniany, a jego historia transferu zostanie usunięta z tego urządzenia.",
|
||||
"nl": "‘{transferName}’ wordt niet meer gedeeld en de overdrachtsgeschiedenis wordt van dit apparaat verwijderd.",
|
||||
"ru": "Общий доступ к «{transferName}» будет остановлен, а история передачи будет удалена с этого устройства."
|
||||
}
|
||||
},
|
||||
"transfer_delete_title": {
|
||||
@@ -3998,6 +4449,20 @@
|
||||
"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": {
|
||||
"context": "Receiver status: the request was refused.",
|
||||
"translations": {
|
||||
@@ -4164,6 +4629,23 @@
|
||||
"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": {
|
||||
"context": "Placeholder shown when a device-info or metadata value can't be read.",
|
||||
"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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user