diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..8fa433c
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,47 @@
+name: Docs website
+
+on:
+ pull_request:
+ paths:
+ - "docs/**"
+ - ".github/workflows/docs.yml"
+ push:
+ branches:
+ - master
+ paths:
+ - "docs/**"
+ - ".github/workflows/docs.yml"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: docs-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ quality:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ defaults:
+ run:
+ working-directory: docs
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ cache: npm
+ cache-dependency-path: docs/package-lock.json
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Type-check
+ run: npm run typecheck
+
+ - name: Build
+ run: npm run build
diff --git a/.github/workflows/linux-packages.yml b/.github/workflows/linux-packages.yml
new file mode 100644
index 0000000..1e0c60a
--- /dev/null
+++ b/.github/workflows/linux-packages.yml
@@ -0,0 +1,344 @@
+name: Linux packages
+
+on:
+ pull_request:
+ paths:
+ - ".github/workflows/linux-packages.yml"
+ - "packaging/linux/**"
+ - "assets/linux/**"
+ - "desktopApp/**"
+ - "shared/**"
+ - "crates/vnidrop/**"
+ - "Cargo.toml"
+ - "Cargo.lock"
+ - "LICENSE"
+ - "build.gradle.kts"
+ - "settings.gradle.kts"
+ - "gradle.properties"
+ - "gradle/**"
+ - "gradlew"
+ push:
+ tags:
+ - "v*.*.*"
+ workflow_dispatch:
+ inputs:
+ version:
+ description: Release version in MAJOR.MINOR.PATCH form
+ required: true
+ default: "1.0.0"
+ type: string
+
+permissions:
+ contents: read
+
+concurrency:
+ group: linux-packages-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+defaults:
+ run:
+ shell: bash
+
+jobs:
+ build-deb:
+ name: Build Debian package (x64)
+ runs-on: ubuntu-22.04
+ timeout-minutes: 90
+ env:
+ CARGO_TERM_COLOR: always
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Install packaging tools
+ run: |
+ sudo apt-get update
+ sudo apt-get install --yes fakeroot unzip
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ with:
+ distribution: temurin
+ java-version: "21.0.11+10.0.LTS"
+
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
+
+ - name: Set up Rust 1.91
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1
+ with:
+ toolchain: "1.91.0"
+
+ - name: Cache Cargo
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ key: linux-deb-x64-cargo-1.91.0-${{ hashFiles('Cargo.lock') }}
+ restore-keys: |
+ linux-deb-x64-cargo-1.91.0-
+
+ - name: Resolve version
+ id: version
+ env:
+ REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
+ run: |
+ version=$(packaging/linux/resolve-version.sh "$REQUESTED_VERSION")
+ echo "app=$version" >> "$GITHUB_OUTPUT"
+
+ - name: Test and build Debian package
+ run: |
+ ./gradlew \
+ :shared:jvmTest \
+ :desktopApp:packageReleaseDeb \
+ -Pvnidrop.version=${{ steps.version.outputs.app }} \
+ -Pvnidrop.desktop.rustVariant=release \
+ -Pvnidrop.diagnostics.included=false \
+ --no-daemon \
+ --no-configuration-cache \
+ --stacktrace
+
+ - name: Validate Debian package
+ id: package
+ env:
+ VERSION: ${{ steps.version.outputs.app }}
+ run: |
+ mapfile -t packages < <(find desktopApp/build/compose/binaries/main-release/deb -maxdepth 1 -type f -name '*.deb')
+ if (( ${#packages[@]} != 1 )); then
+ echo "Expected exactly one Debian package, found ${#packages[@]}" >&2
+ exit 1
+ fi
+
+ output_directory=build/release/linux/deb
+ output_name="vnidrop_${VERSION}-1_amd64.deb"
+ mkdir -p "$output_directory"
+ cp "${packages[0]}" "$output_directory/$output_name"
+ packaging/linux/verify-package.sh deb "$VERSION" "$output_directory/$output_name"
+ (
+ cd "$output_directory"
+ sha256sum "$output_name" > "$output_name.sha256"
+ )
+
+ - name: Upload Debian artifact
+ if: github.event_name != 'pull_request'
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: vnidrop-${{ steps.version.outputs.app }}-linux-deb-x64
+ path: build/release/linux/deb/
+ if-no-files-found: error
+ retention-days: 14
+ compression-level: 0
+
+ - name: Summarize Debian package
+ run: |
+ echo "### Debian package" >> "$GITHUB_STEP_SUMMARY"
+ echo "- Version: ${{ steps.version.outputs.app }}-1" >> "$GITHUB_STEP_SUMMARY"
+ echo "- Architecture: amd64" >> "$GITHUB_STEP_SUMMARY"
+ echo "- Build baseline: Ubuntu 22.04" >> "$GITHUB_STEP_SUMMARY"
+
+ build-rpm:
+ name: Build RPM package (x64)
+ runs-on: ubuntu-24.04
+ container:
+ image: registry.fedoraproject.org/fedora:43
+ volumes:
+ - /usr/local/lib/android/sdk:/usr/local/lib/android/sdk
+ timeout-minutes: 90
+ env:
+ ANDROID_HOME: /usr/local/lib/android/sdk
+ ANDROID_SDK_ROOT: /usr/local/lib/android/sdk
+ CARGO_TERM_COLOR: always
+
+ steps:
+ - name: Install build and packaging tools
+ run: |
+ dnf install --assumeyes \
+ alsa-lib \
+ cpio \
+ curl \
+ cups-libs \
+ desktop-file-utils \
+ findutils \
+ fontconfig \
+ freetype \
+ gcc \
+ gcc-c++ \
+ git \
+ gzip \
+ gtk3 \
+ libX11 \
+ libXext \
+ libXi \
+ libXrandr \
+ libXrender \
+ libXtst \
+ make \
+ mesa-libGL \
+ rpm-build \
+ tar \
+ unzip \
+ which \
+ xz \
+ zstd
+
+ - name: Checkout
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ with:
+ distribution: temurin
+ java-version: "21.0.11+10.0.LTS"
+
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
+
+ - name: Set up Rust 1.91
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1
+ with:
+ toolchain: "1.91.0"
+
+ - name: Cache Cargo
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ key: linux-rpm-x64-cargo-1.91.0-${{ hashFiles('Cargo.lock') }}
+ restore-keys: |
+ linux-rpm-x64-cargo-1.91.0-
+
+ - name: Resolve version
+ id: version
+ env:
+ REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
+ run: |
+ version=$(packaging/linux/resolve-version.sh "$REQUESTED_VERSION")
+ echo "app=$version" >> "$GITHUB_OUTPUT"
+
+ - name: Build RPM package
+ run: |
+ ./gradlew \
+ :desktopApp:packageReleaseRpm \
+ -Pvnidrop.version=${{ steps.version.outputs.app }} \
+ -Pvnidrop.desktop.rustVariant=release \
+ -Pvnidrop.diagnostics.included=false \
+ --no-daemon \
+ --no-configuration-cache \
+ --stacktrace
+
+ - name: Validate RPM package
+ env:
+ VERSION: ${{ steps.version.outputs.app }}
+ run: |
+ mapfile -t packages < <(find desktopApp/build/compose/binaries/main-release/rpm -maxdepth 1 -type f -name '*.rpm')
+ if (( ${#packages[@]} != 1 )); then
+ echo "Expected exactly one RPM package, found ${#packages[@]}" >&2
+ exit 1
+ fi
+
+ output_directory=build/release/linux/rpm
+ output_name="vnidrop-${VERSION}-1.x86_64.rpm"
+ mkdir -p "$output_directory"
+ cp "${packages[0]}" "$output_directory/$output_name"
+ packaging/linux/verify-package.sh rpm "$VERSION" "$output_directory/$output_name"
+ (
+ cd "$output_directory"
+ sha256sum "$output_name" > "$output_name.sha256"
+ )
+
+ - name: Upload RPM artifact
+ if: github.event_name != 'pull_request'
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: vnidrop-${{ steps.version.outputs.app }}-linux-rpm-x64
+ path: build/release/linux/rpm/
+ if-no-files-found: error
+ retention-days: 14
+ compression-level: 0
+
+ - name: Summarize RPM package
+ run: |
+ echo "### RPM package" >> "$GITHUB_STEP_SUMMARY"
+ echo "- Version: ${{ steps.version.outputs.app }}-1" >> "$GITHUB_STEP_SUMMARY"
+ echo "- Architecture: x86_64" >> "$GITHUB_STEP_SUMMARY"
+ echo "- Build environment: Fedora 43" >> "$GITHUB_STEP_SUMMARY"
+
+ publish-release:
+ name: Publish GitHub Release assets
+ if: github.event_name == 'push' && github.ref_type == 'tag'
+ needs:
+ - build-deb
+ - build-rpm
+ runs-on: ubuntu-22.04
+ timeout-minutes: 15
+ permissions:
+ contents: write
+
+ steps:
+ - name: Checkout release history
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Verify tag is on master
+ 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: Download Linux artifacts
+ uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
+ with:
+ pattern: vnidrop-*-linux-*-x64
+ path: build/release/linux
+ merge-multiple: true
+
+ - name: Verify artifacts and checksums
+ run: |
+ cd build/release/linux
+ shopt -s nullglob
+ deb_packages=(*.deb)
+ rpm_packages=(*.rpm)
+ checksum_files=(*.sha256)
+ if (( ${#deb_packages[@]} != 1 || ${#rpm_packages[@]} != 1 || ${#checksum_files[@]} != 2 )); then
+ echo "Expected one DEB, one RPM, and two checksum sidecars" >&2
+ exit 1
+ fi
+ version=${GITHUB_REF_NAME#v}
+ if [[ ${deb_packages[0]} != "vnidrop_${version}-1_amd64.deb" || ${rpm_packages[0]} != "vnidrop-${version}-1.x86_64.rpm" ]]; then
+ echo "Downloaded package names do not match tag $GITHUB_REF_NAME" >&2
+ exit 1
+ fi
+ sha256sum --check "${checksum_files[@]}"
+ sha256sum "${deb_packages[@]}" "${rpm_packages[@]}" > SHA256SUMS
+ rm -- "${checksum_files[@]}"
+
+ - name: Publish GitHub Release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ run: |
+ tag=${GITHUB_REF_NAME}
+ version=${tag#v}
+ if gh release view "$tag" >/dev/null 2>&1; then
+ echo "GitHub Release $tag already exists; refusing to replace its assets" >&2
+ exit 1
+ fi
+ gh release create "$tag" \
+ build/release/linux/*.deb \
+ build/release/linux/*.rpm \
+ build/release/linux/SHA256SUMS \
+ --verify-tag \
+ --title "VniDrop $version" \
+ --generate-notes
diff --git a/.github/workflows/windows-store.yml b/.github/workflows/windows-store.yml
new file mode 100644
index 0000000..4192e79
--- /dev/null
+++ b/.github/workflows/windows-store.yml
@@ -0,0 +1,157 @@
+name: Windows Store package
+
+on:
+ pull_request:
+ paths:
+ - ".github/workflows/windows-store.yml"
+ - "packaging/windows/**"
+ - "assets/windows/**"
+ - "desktopApp/**"
+ - "shared/**"
+ - "crates/vnidrop/**"
+ - "Cargo.toml"
+ - "Cargo.lock"
+ - "build.gradle.kts"
+ - "settings.gradle.kts"
+ - "gradle.properties"
+ - "gradle/**"
+ - "gradlew"
+ - "gradlew.bat"
+ push:
+ tags:
+ - "v*.*.*"
+ workflow_dispatch:
+ inputs:
+ version:
+ description: Release version in MAJOR.MINOR.PATCH form
+ required: true
+ default: "1.0.0"
+ type: string
+
+permissions:
+ contents: read
+
+concurrency:
+ group: windows-store-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+jobs:
+ build-msix:
+ name: Build unsigned Store MSIX (x64)
+ runs-on: windows-2025
+ timeout-minutes: 90
+ env:
+ CARGO_TERM_COLOR: always
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ with:
+ distribution: temurin
+ java-version: "21.0.11+10.0.LTS"
+
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
+
+ - name: Set up Rust 1.91
+ shell: pwsh
+ run: |
+ rustup toolchain install 1.91.0-x86_64-pc-windows-msvc --profile minimal
+ rustup default 1.91.0-x86_64-pc-windows-msvc
+ rustc --version --verbose
+ cargo --version
+
+ - name: Cache Cargo
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ key: windows-x64-cargo-1.91.0-${{ hashFiles('Cargo.lock') }}
+ restore-keys: |
+ windows-x64-cargo-1.91.0-
+
+ - name: Resolve Store version
+ id: version
+ shell: pwsh
+ env:
+ REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
+ run: |
+ $version = $env:REQUESTED_VERSION
+ if ($env:GITHUB_REF_TYPE -eq "tag") {
+ if ($env:GITHUB_REF_NAME -notmatch "^v[0-9]+\.[0-9]+\.[0-9]+$") {
+ throw "Store release tags must use vMAJOR.MINOR.PATCH"
+ }
+ $version = $env:GITHUB_REF_NAME.Substring(1)
+ }
+
+ if ($version -notmatch "^[0-9]+\.[0-9]+\.[0-9]+$") {
+ throw "Version must use MAJOR.MINOR.PATCH"
+ }
+ $parts = $version.Split(".")
+ for ($index = 0; $index -lt $parts.Count; $index++) {
+ $part = $parts[$index]
+ $number = 0
+ if (-not [int]::TryParse($part, [ref] $number) -or $number.ToString() -ne $part) {
+ throw "Version components must be canonical integers"
+ }
+ if ($number -lt $(if ($index -eq 0) { 1 } else { 0 }) -or $number -gt 65535) {
+ throw "Version components must be between 0 and 65535, with a non-zero major"
+ }
+ }
+
+ "app=$version" >> $env:GITHUB_OUTPUT
+ "package=$version.0" >> $env:GITHUB_OUTPUT
+
+ - name: Test and build release app image
+ shell: pwsh
+ run: |
+ $arguments = @(
+ ":shared:jvmTest"
+ ":desktopApp:createReleaseDistributable"
+ "-Pvnidrop.version=${{ steps.version.outputs.app }}"
+ "-Pvnidrop.desktop.rustVariant=release"
+ "-Pvnidrop.diagnostics.included=false"
+ "--no-daemon"
+ "--no-configuration-cache"
+ "--stacktrace"
+ )
+ & .\gradlew.bat @arguments
+ if ($LASTEXITCODE -ne 0) {
+ throw "Gradle release build failed with exit code $LASTEXITCODE"
+ }
+
+ - name: Create and validate Store package
+ shell: pwsh
+ run: |
+ $arguments = @{
+ Version = "${{ steps.version.outputs.app }}"
+ AppImage = ".\desktopApp\build\compose\binaries\main-release\app\VniDrop"
+ OutputDirectory = ".\build\release\windows"
+ }
+ & .\packaging\windows\build-msix.ps1 @arguments
+
+ - name: Upload Store artifacts
+ if: github.event_name != 'pull_request'
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: vnidrop-${{ steps.version.outputs.app }}-windows-store-x64
+ path: build/release/windows/
+ if-no-files-found: error
+ retention-days: 90
+ compression-level: 0
+
+ - name: Summarize package
+ shell: pwsh
+ run: |
+ "### Windows Store package" >> $env:GITHUB_STEP_SUMMARY
+ "- App version: ${{ steps.version.outputs.app }}" >> $env:GITHUB_STEP_SUMMARY
+ "- MSIX version: ${{ steps.version.outputs.package }}" >> $env:GITHUB_STEP_SUMMARY
+ "- Architecture: x64" >> $env:GITHUB_STEP_SUMMARY
+ "- Signing: unsigned Store submission; Microsoft signs after certification" >> $env:GITHUB_STEP_SUMMARY
diff --git a/.gitignore b/.gitignore
index 94459d4..1bab3e9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,3 +22,4 @@ target/
# Local design export scratch
output/
+.screenshots
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..55c0122
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,161 @@
+# VniDrop Code of Conduct
+
+## Our Pledge
+
+We pledge to make our community welcoming, safe, and equitable for all.
+
+We are committed to fostering an environment that respects and promotes the
+dignity, rights, and contributions of all individuals, regardless of
+characteristics including race, ethnicity, caste, color, age, physical
+characteristics, neurodiversity, disability, sex or gender, gender identity or
+expression, sexual orientation, language, philosophy or religion, national or
+social origin, socio-economic position, level of education, or other status.
+The same privileges of participation are extended to everyone who participates
+in good faith and in accordance with this Code of Conduct.
+
+## Encouraged Behaviors
+
+While acknowledging differences in social norms, we all strive to meet our
+community's expectations for positive behavior. We also understand that our
+words and actions may be interpreted differently than we intend based on
+culture, background, or native language.
+
+With these considerations in mind, we agree to behave mindfully toward each
+other and act in ways that center our shared values, including:
+
+1. Respecting the purpose of our community, our activities, and our ways of
+ gathering.
+2. Engaging kindly and honestly with others.
+3. Respecting different viewpoints and experiences.
+4. Taking responsibility for our actions and contributions.
+5. Gracefully giving and accepting constructive feedback.
+6. Committing to repairing harm when it occurs.
+7. Behaving in other ways that promote and sustain the well-being of our
+ community.
+
+## Restricted Behaviors
+
+We agree to restrict the following behaviors in our community. Instances,
+threats, and promotion of these behaviors are violations of this Code of
+Conduct.
+
+1. **Harassment.** Violating explicitly expressed boundaries or engaging in
+ unnecessary personal attention after any clear request to stop.
+2. **Character attacks.** Making insulting, demeaning, or pejorative comments
+ directed at a community member or group of people.
+3. **Stereotyping or discrimination.** Characterizing anyone's personality or
+ behavior on the basis of immutable identities or traits.
+4. **Sexualization.** Behaving in a way that would generally be considered
+ inappropriately intimate in the context or purpose of the community.
+5. **Violating confidentiality.** Sharing or acting on someone's personal or
+ private information without their permission.
+6. **Endangerment.** Causing, encouraging, or threatening violence or other
+ harm toward any person or group.
+7. **Behaving in other ways that threaten the well-being of our community.**
+
+### Other Restrictions
+
+1. **Misleading identity.** Impersonating someone else for any reason, or
+ pretending to be someone else to evade enforcement actions.
+2. **Failing to credit sources.** Not properly crediting the sources of content
+ you contribute.
+3. **Promotional materials.** Sharing marketing or other commercial content in
+ a way that is outside the norms of the community.
+4. **Irresponsible communication.** Failing to responsibly present content
+ which includes, links, or describes any other restricted behaviors.
+
+## Reporting an Issue
+
+Tensions can occur between community members even when they are trying their
+best to collaborate. Not every conflict represents a Code of Conduct violation,
+and this Code of Conduct reinforces encouraged behaviors and norms that can help
+avoid conflicts and minimize harm.
+
+To report a possible violation, contact a VniDrop maintainer privately using the
+contact information on their GitHub profile. Do not disclose sensitive details
+in a public issue. If a report concerns a maintainer, contact a different
+maintainer who is not involved in the incident.
+
+Include the relevant links, dates, context, and any supporting material you are
+comfortable sharing. Community Moderators will take reports seriously and make
+every effort to respond promptly. They will investigate reports by reviewing
+available messages, logs, and other evidence, or by interviewing witnesses and
+participants. They will keep investigation and enforcement actions as
+transparent as possible while prioritizing safety and confidentiality.
+
+Enforcement actions are carried out privately with the involved parties, but
+communicating to the whole community may be part of a mutually agreed-upon
+resolution.
+
+## Addressing and Repairing Harm
+
+If an investigation finds that this Code of Conduct has been violated, the
+following enforcement ladder may be used to determine how best to repair harm,
+based on the incident's impact on the individuals involved and the community as
+a whole. Depending on the severity of a violation, lower rungs on the ladder may
+be skipped.
+
+### 1. Warning
+
+- **Event:** A violation involving a single incident or series of incidents.
+- **Consequence:** A private, written warning from the Community Moderators.
+- **Repair:** Examples include a private written apology, acknowledgement of
+ responsibility, and seeking clarification on expectations.
+
+### 2. Temporarily Limited Activities
+
+- **Event:** A repeated incidence of a violation that previously resulted in a
+ warning, or the first incidence of a more serious violation.
+- **Consequence:** A private, written warning with a time-limited cooldown
+ period designed to underscore the seriousness of the situation and give the
+ community members involved time to process the incident. The cooldown period
+ may be limited to particular communication channels or interactions with
+ particular community members.
+- **Repair:** Examples include making an apology, using the cooldown period to
+ reflect on actions and impact, and being thoughtful about re-entering
+ community spaces after the period is over.
+
+### 3. Temporary Suspension
+
+- **Event:** A pattern of repeated violations which the Community Moderators
+ have tried to address with warnings, or a single serious violation.
+- **Consequence:** A private written warning with conditions for return from
+ suspension. In general, temporary suspensions give the person being suspended
+ time to reflect upon their behavior and possible corrective actions.
+- **Repair:** Examples include respecting the spirit of the suspension, meeting
+ the specified conditions for return, and being thoughtful about how to
+ reintegrate with the community when the suspension is lifted.
+
+### 4. Permanent Ban
+
+- **Event:** A pattern of repeated Code of Conduct violations that other steps
+ on the ladder have failed to resolve, or a violation so serious that the
+ Community Moderators determine there is no way to keep the community safe
+ with this person as a member.
+- **Consequence:** Access to all community spaces, tools, and communication
+ channels is removed. Permanent bans should be rarely used, have strong
+ reasoning behind them, and only be used if other remedies have failed to
+ change the behavior.
+- **Repair:** There is no possible repair in cases of this severity.
+
+This enforcement ladder is a guideline. It does not limit the ability of
+Community Moderators to use their discretion and judgment in the best interests
+of the VniDrop community.
+
+## Scope
+
+This Code of Conduct applies within all VniDrop community spaces and also when
+an individual is officially representing VniDrop in public or other spaces.
+Examples of representation include using an official email address, posting via
+an official social media account, or acting as an appointed representative at
+an online or offline event.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant, version
+3.0](https://www.contributor-covenant.org/version/3/0/).
+
+Contributor Covenant is stewarded by the Organization for Ethical Source. This
+adapted Code of Conduct is licensed under [CC BY-SA
+4.0](https://creativecommons.org/licenses/by-sa/4.0/). The enforcement ladder
+was inspired by the work of Mozilla's code of conduct team.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..58aaf30
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,167 @@
+# Contributing to VniDrop
+
+Thank you for helping improve VniDrop. Contributions may include bug reports,
+feature proposals, documentation, tests, design feedback, and code.
+
+By participating, you agree to follow the project
+[Code of Conduct](CODE_OF_CONDUCT.md).
+
+## Before You Start
+
+- Search existing issues and pull requests before opening a duplicate.
+- For a substantial feature or architecture change, open an issue first so the
+ approach and platform impact can be discussed.
+- Keep each change focused. Avoid unrelated refactors, dependency upgrades, or
+ repository-wide formatting.
+- Report suspected vulnerabilities through the private process in
+ [`SECURITY.md`](SECURITY.md), never in a public issue with technical details.
+- Never include secrets, private tickets, file contents, key material, or
+ passphrases in an issue, log, test fixture, commit, or pull request.
+
+## Development Setup
+
+Clone the repository and create a branch from an up-to-date `master`:
+
+```bash
+git clone https://github.com/vnidrop/vnidrop.git
+cd vnidrop
+git switch master
+git pull --ff-only
+git switch -c feat/short-description
+```
+
+Use a branch name that describes the outcome, such as
+`feat/folder-share`, `fix/cancel-export-hang`, or `docs/contributing`.
+
+Install the tools needed for the area you plan to change:
+
+- JDK 17 or newer for Gradle and application builds
+- Rust stable with `rustfmt` and Clippy for the transfer core
+- Android SDK and NDK for Android builds
+- Xcode on macOS for iOS builds and simulator tests
+- Node.js 22.12 or newer for the optional diagnostics service
+
+The first Rust and Gradle builds may take several minutes while dependencies are
+downloaded and native components are compiled.
+
+## Repository Structure
+
+| Path | Purpose |
+|------|---------|
+| `crates/vnidrop/` | Rust transfer core, persistence, approval, and streaming |
+| `shared/` | Shared Kotlin Multiplatform UI and platform bridges |
+| `androidApp/` | Android application shell |
+| `iosApp/` | iOS application shell |
+| `desktopApp/` | Desktop JVM application shell |
+| `services/diagnostics-api/` | Optional Cloudflare diagnostics service |
+
+Read the nearest contributor guidance before editing:
+
+- [`AGENTS.md`](AGENTS.md) contains repository-wide engineering rules.
+- [`crates/vnidrop/AGENTS.md`](crates/vnidrop/AGENTS.md) covers the Rust core.
+- [`shared/AGENTS.md`](shared/AGENTS.md) covers Compose and Kotlin
+ Multiplatform work.
+
+## Engineering Expectations
+
+VniDrop follows a few important design constraints:
+
+- File transfer payloads are streamed by Rust and should not be routed through
+ the Kotlin heap as the primary design.
+- Android directory sharing expands SAF trees into individual file descriptors;
+ directory file descriptors are not passed to Rust.
+- Approval and access checks must not be weakened for convenience.
+- Receive publishing must preserve the existing no-overwrite behavior.
+- Locks and synchronous guards must not be held across Rust `.await` points.
+- Bug fixes require a regression test at the lowest layer that demonstrates the
+ failure.
+
+Match the style of nearby code. Comments should explain non-obvious invariants,
+platform constraints, concurrency behavior, or design decisions instead of
+restating the code.
+
+## Testing
+
+Run checks from the repository root. Choose the suite for the files you changed.
+
+### Rust Core
+
+```bash
+cargo fmt --all
+cargo clippy --workspace --all-targets -- -D warnings
+cargo test -p vnidrop
+```
+
+For cancel, export, or output-sink changes, also run:
+
+```bash
+cargo test -p vnidrop --test output_sink
+```
+
+For broader core changes, run the complete workspace suite:
+
+```bash
+cargo test --workspace --all-targets
+```
+
+### Shared Kotlin and Compose
+
+```bash
+./gradlew :shared:jvmTest
+```
+
+Platform-specific checks may also be appropriate:
+
+```bash
+./gradlew :shared:testAndroidHostTest
+./gradlew :shared:iosSimulatorArm64Test
+./gradlew :androidApp:assembleDebug
+```
+
+### Diagnostics Service
+
+```bash
+cd services/diagnostics-api
+npm ci
+npm run check
+```
+
+If a required check cannot run on your machine, explain why in the pull request
+and list the checks you did run.
+
+## Commits
+
+Use concise commit messages that describe the outcome. The repository commonly
+uses Conventional Commit-style subjects:
+
+```text
+feat(core): add folder transfer metadata
+fix(ui): preserve receive progress after rotation
+docs: clarify desktop setup
+```
+
+Create signed commits when your repository configuration requires signing. Do
+not bypass a signing requirement with an unsigned commit.
+
+## Pull Requests
+
+Open pull requests against `master`. A good pull request should:
+
+1. Explain what changed and why.
+2. Stay limited to one coherent outcome.
+3. Link the relevant issue, when one exists.
+4. Describe platform or compatibility implications.
+5. Include regression coverage for bug fixes and behavior changes.
+6. Provide an executable test plan with the exact commands or concrete manual
+ scenarios used for verification.
+7. Avoid generated files, unrelated formatting, and dependency changes unless
+ they are required by the contribution.
+
+Review feedback is part of the collaboration process. Keep follow-up commits
+focused, and resolve review threads only after the concern has been addressed.
+
+## Licensing
+
+VniDrop is distributed under the [Apache License 2.0](LICENSE). Unless explicitly
+stated otherwise, contributions accepted into this repository are distributed
+under the same license.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/README.md b/README.md
index 49b0343..454ee63 100644
--- a/README.md
+++ b/README.md
@@ -1,36 +1,143 @@
-This is a Kotlin Multiplatform project targeting Android, iOS, Desktop (JVM).
+
+
+
-* [/iosApp](./iosApp/iosApp) contains an iOS application. Even if you’re sharing your UI with Compose Multiplatform,
- you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project.
+
VniDrop
-* [/shared](./shared/src) is for code that will be shared across your Compose Multiplatform applications.
- It contains several subfolders:
- - [commonMain](./shared/src/commonMain/kotlin) is for code that’s common for all targets.
- - Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name.
- For example, if you want to use Apple’s CoreCrypto for the iOS part of your Kotlin app,
- the [iosMain](./shared/src/iosMain/kotlin) folder would be the right place for such calls.
- Similarly, if you want to edit the Desktop (JVM) specific part, the [jvmMain](./shared/src/jvmMain/kotlin)
- folder is the appropriate location.
+
+ Send files directly. Stay in control of who receives them.
+
-### Running the apps
+
+ Cross-platform file transfer for Android, iOS, macOS, Windows, and Linux.
+
-Use the run configurations provided by the run widget in your IDE's toolbar. You can also use these commands and
-options:
+
+
+
+
+
+
-- Android app: `./gradlew :androidApp:assembleDebug`
-- Desktop app:
- - Hot reload: `./gradlew :desktopApp:hotRun --auto`
- - Standard run: `./gradlew :desktopApp:run`
-- iOS app: open the [/iosApp](./iosApp) directory in Xcode and run it from there.
+VniDrop moves files and folders from one device to another without first
+uploading them to a file-hosting service. Choose what to send, decide who may
+receive it, and share a small invitation. The receiving device uses that
+invitation to find the sender and request the files.
-### Running tests
+There is no account to create and no cloud copy of the transfer waiting after
+you are done. The sender remains in control and can stop sharing at any time.
-Use the run button in your IDE's editor gutter, or run tests using Gradle tasks:
+## How a transfer works
-- Android tests: `./gradlew :shared:testAndroidHostTest`
-- Desktop tests: `./gradlew :shared:jvmTest`
-- iOS tests: `./gradlew :shared:iosSimulatorArm64Test`
+1. **Choose files or a folder.** VniDrop prepares the selection on the sender's
+ device and keeps the original folder structure.
+2. **Create an invitation.** The app produces a small VniDrop invitation that
+ describes the transfer and how to reach the sender. Share it as a QR code, an
+ NFC tag, or a `.vnd` file.
+3. **Connect to the sender.** The receiver opens the invitation. Iroh helps the
+ devices find each other and establishes an authenticated, end-to-end
+ encrypted connection.
+4. **Request access.** By default, the sender sees who wants the transfer and
+ chooses whether to approve or refuse the request.
+5. **Stream and verify.** After access is granted, `iroh-blobs` streams the files
+ and verifies their content while it arrives. VniDrop saves each file directly
+ to the chosen destination without replacing an existing file.
+6. **Stay in control.** The sender can follow each receiver's progress, cancel a
+ transfer, or stop sharing so the invitation can no longer be used.
----
+Iroh tries to connect the devices directly, including across home routers and
+mobile networks. If a direct path cannot be established, it can forward the
+same end-to-end encrypted connection through a relay. The relay forwards
+encrypted packets; it is not a VniDrop file store.
-Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html)…
+## Why Iroh and `iroh-blobs`?
+
+VniDrop combines a networking layer with its own sharing rules:
+
+| Layer | What it does |
+|-------|--------------|
+| [Iroh](https://docs.iroh.computer/) | Gives each device a secure identity, helps devices find one another, creates encrypted connections, and falls back to relays when a direct path is unavailable. |
+| [`iroh-blobs`](https://docs.rs/iroh-blobs/0.103.0/iroh_blobs/) | Turns files into content-addressed, verified streams, so corrupted or unexpected data is detected while receiving. Multiple files are grouped into one collection. |
+| **VniDrop** | Adds human-friendly invitations, receiver approval, per-transfer access rules, progress, history, cancellation, and safe saving on each operating system. |
+
+Content addressing is useful here because the invitation identifies exactly
+what was shared. The receiver does not simply trust a filename or claimed size:
+the incoming content must match its expected hash.
+
+## Approval is part of the transfer
+
+A VniDrop invitation helps two devices meet, but the default invitation is not
+automatic permission to download.
+
+| Access mode | Behavior |
+|-------------|----------|
+| **Ask before each download** | The default. Every new receiver asks first, and the sender can approve or refuse the request. Approval gives that device temporary access to this transfer. |
+| **Anyone with this transfer** | No interactive approval is required. Anyone holding the invitation may receive the files until the sender stops sharing. This mode is intended only for non-sensitive items. |
+
+VniDrop starts from a deny-by-default position: it serves only the content in an
+active share, and only when that receiver's access mode allows it. Unknown
+content requests are rejected.
+
+Treat an invitation like a private access link. Share it only with the intended
+people, especially when using **Anyone with this transfer**.
+
+## What VniDrop supports
+
+- Individual files, multiple files, and complete folders
+- QR codes, NFC tags, and portable `.vnd` invitation files
+- Per-receiver requests, approvals, progress, and delivery status
+- Cancel, stop sharing, and local transfer history
+- Safe receive destinations that do not silently overwrite existing files
+- Android, iOS, and desktop apps built from a shared Compose Multiplatform UI
+- Opt-in diagnostics with transfer contents, invitations, and file paths
+ excluded
+
+## Privacy by design
+
+- **No hosted transfer copy.** VniDrop does not upload file contents to its
+ diagnostics service or a VniDrop storage bucket.
+- **Encrypted in transit.** Iroh connections are authenticated and encrypted
+ end to end, including when a relay is needed.
+- **Local control.** Transfer history and sharing state stay on the device.
+- **Sensitive invitations.** An invitation can grant access, so it is
+ deliberately excluded from product logs and diagnostics.
+- **Explicit access.** Approval is required by default, and stopping a share
+ removes access immediately.
+
+Please report suspected vulnerabilities through the private process in
+[`SECURITY.md`](SECURITY.md), not through a public issue.
+
+## Project status
+
+VniDrop is in early development. The transfer engine, application experience,
+and stored history format may change before a stable release. Build from source
+if you want to try the current version.
+
+```bash
+git clone https://github.com/vnidrop/vnidrop.git
+cd vnidrop
+
+# Desktop
+./gradlew :desktopApp:run
+
+# Android debug build
+./gradlew :androidApp:assembleDebug
+
+# iOS
+open iosApp/iosApp.xcodeproj
+```
+
+See [`CONTRIBUTING.md`](CONTRIBUTING.md) for prerequisites, development setup,
+testing, and pull request guidance.
+
+## Learn more
+
+- [`crates/vnidrop/CORE_FLOW.md`](crates/vnidrop/CORE_FLOW.md) — protocol,
+ approval, durability, and file-handling details
+- [`CONTRIBUTING.md`](CONTRIBUTING.md) — development and contribution guide
+- [`SECURITY.md`](SECURITY.md) — security policy and private reporting
+- [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md) — community standards
+
+## License
+
+VniDrop is available under the [Apache License 2.0](LICENSE).
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..61f77eb
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,102 @@
+# VniDrop Security Policy
+
+VniDrop transfers files directly between devices and treats invitations as
+capabilities. Security reports are taken seriously, especially when they affect
+transfer authorization, file integrity, peer privacy, or the handling of local
+files.
+
+## Supported Versions
+
+VniDrop is currently pre-release software and does not yet have tagged stable
+releases. Security fixes are developed for the latest commit on `master`.
+
+| Version | Supported |
+|---------|-----------|
+| Latest `master` | Yes |
+| Older commits and unofficial builds | No |
+
+Before reporting an issue, check whether it is reproducible on the latest
+`master` when it is safe to do so. This table will be updated when versioned
+releases are published.
+
+## Reporting a Vulnerability
+
+Do not disclose a suspected vulnerability, proof of concept, invitation ticket,
+private file, or sensitive log in a public issue or discussion.
+
+To make a private report:
+
+1. Contact a VniDrop maintainer privately using the contact information on their
+ GitHub profile.
+2. If no private contact method is available, open a public issue titled
+ **Security contact request**. Include no vulnerability details. A maintainer
+ will arrange a private channel for the report.
+
+Code of Conduct incidents should instead follow the private reporting process in
+[`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md).
+
+### What to Include
+
+Provide as much of the following information as you safely can:
+
+- A concise description of the vulnerability and its potential impact
+- The affected commit, platform, and application version
+- Reproduction steps or a minimal proof of concept using test data
+- Required access, permissions, or user interaction
+- Relevant logs or screenshots with tickets, paths, endpoint identifiers,
+ credentials, and personal data redacted
+- Any suggested mitigation or fix
+- Whether the issue has been disclosed to anyone else
+
+Do not attach real user files or reusable invitation tickets. Generate isolated
+test fixtures where possible.
+
+## Security-Sensitive Areas
+
+Reports are especially useful when they involve:
+
+- Bypassing transfer approval, access policy, or provider authorization
+- Forging, leaking, replaying, or incorrectly accepting invitation tickets
+- Serving blobs that are not registered for an active share
+- Path traversal, symlink attacks, file overwrite, or unsafe temporary-file
+ publication
+- Unsafe handling of Android file descriptors, SAF permissions, or iOS
+ security-scoped resources
+- Remote code execution, memory-safety failures, or denial of service caused by
+ untrusted peer input
+- Exposure of file contents, local paths, tickets, endpoint identifiers,
+ credentials, or other sensitive values through diagnostics or logs
+- Authentication, authorization, or data-isolation failures in the diagnostics
+ service
+
+The transfer protocol and file-publication invariants are documented in
+[`crates/vnidrop/CORE_FLOW.md`](crates/vnidrop/CORE_FLOW.md).
+
+## Coordinated Disclosure
+
+After receiving a report, maintainers will aim to:
+
+1. Confirm receipt and establish a private communication channel.
+2. Reproduce and assess the issue, including affected platforms and versions.
+3. Develop and verify a fix without weakening existing security boundaries.
+4. Coordinate the release and public disclosure with the reporter.
+5. Credit the reporter if they want public acknowledgement.
+
+Response and remediation times depend on severity and complexity. Please allow
+maintainers a reasonable opportunity to investigate and release a fix before
+publishing technical details.
+
+## Research Guidelines
+
+When investigating VniDrop:
+
+- Use devices, accounts, files, and peers that you own or have permission to
+ test.
+- Minimize access to personal data and stop testing if you encounter data that
+ does not belong to you.
+- Avoid privacy violations, service disruption, data destruction, and testing
+ that affects other users.
+- Keep vulnerability details confidential until disclosure is coordinated.
+- Follow applicable laws and the project [Code of Conduct](CODE_OF_CONDUCT.md).
+
+Thank you for helping keep VniDrop and its users safe.
diff --git a/androidApp/src/main/res/values/strings.xml b/androidApp/src/main/res/values/strings.xml
index 6a46d21..2125c4f 100644
--- a/androidApp/src/main/res/values/strings.xml
+++ b/androidApp/src/main/res/values/strings.xml
@@ -1,3 +1,3 @@
- vnidrop
+ VniDrop
diff --git a/crates/vnidrop/Cargo.toml b/crates/vnidrop/Cargo.toml
index 7c1e77c..9df0499 100644
--- a/crates/vnidrop/Cargo.toml
+++ b/crates/vnidrop/Cargo.toml
@@ -2,6 +2,7 @@
name = "vnidrop"
version = "0.1.0"
edition = "2021"
+license = "Apache-2.0"
[lib]
name = "vnidrop"
diff --git a/crates/vnidrop/tests/lifecycle.rs b/crates/vnidrop/tests/lifecycle.rs
index ca8a0a5..a6bdd5f 100644
--- a/crates/vnidrop/tests/lifecycle.rs
+++ b/crates/vnidrop/tests/lifecycle.rs
@@ -1,10 +1,13 @@
mod support;
-use std::sync::Arc;
-use std::time::{Duration, Instant};
+use std::sync::{Arc, Condvar, Mutex};
+use std::time::Duration;
use support::{share_path, CoreGuard, RecordingSink, TestNode};
-use vnidrop::{CoreLimits, ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode};
+use vnidrop::{
+ CoreEvent, CoreEventSink, CoreLimits, ShareMetadataInput, ShareSource, SourceKind,
+ TransferAccessMode,
+};
#[test]
fn share_creation_persists_selected_access_mode_atomically() {
@@ -324,55 +327,87 @@ fn source_limit_rejection_creates_no_transfer_state() {
#[test]
fn cancellation_during_import_is_durable() {
let source_dir = tempfile::tempdir().unwrap();
- let source_path = source_dir.path().join("large.bin");
- std::fs::File::create(&source_path)
- .unwrap()
- .set_len(256 * 1024 * 1024)
- .unwrap();
- let sender = TestNode::new();
- let core = sender.core.arc();
+ let source_path = source_dir.path().join("gated.bin");
+ std::fs::write(&source_path, vec![0u8; 256 * 1024]).unwrap();
+ let data_dir = tempfile::tempdir().unwrap();
+ let import_gate = Arc::new(ImportStartedGate::default());
+ let sender = CoreGuard::start(data_dir.path(), import_gate.clone());
+ let core = sender.arc();
let worker = std::thread::spawn(move || {
core.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: source_path.to_string_lossy().to_string(),
- display_name: Some("large.bin".to_string()),
+ display_name: Some("gated.bin".to_string()),
is_directory: false,
}],
ShareMetadataInput {
transfer_id: 25,
- transfer_name: Some("large".to_string()),
+ transfer_name: Some("gated".to_string()),
sender_name: None,
access_mode: TransferAccessMode::ApprovalRequired,
},
)
});
- let started = Instant::now();
- loop {
- if sender
- .core
- .list_transfers()
- .unwrap()
- .iter()
- .any(|transfer| transfer.transfer_id == 25 && transfer.status == "importing")
- {
- break;
- }
- assert!(started.elapsed() < Duration::from_secs(10));
- std::thread::sleep(Duration::from_millis(10));
- }
- sender.core.cancel_transfer(25).unwrap();
+ // The synchronous event callback holds the worker after active-transfer
+ // registration, so cancellation cannot race a fast import to completion.
+ import_gate.wait_until_blocked();
+ let cancel_result = sender.cancel_transfer(25);
+ import_gate.release();
+ cancel_result.unwrap();
assert!(worker.join().unwrap().is_err());
let transfer = sender
- .core
.list_transfers()
.unwrap()
.into_iter()
.find(|transfer| transfer.transfer_id == 25)
.unwrap();
assert_eq!(transfer.status, "cancelled");
- assert_eq!(sender.core.status().active_transfers, 0);
- assert_eq!(sender.core.status().active_shares, 0);
+ assert_eq!(sender.status().active_transfers, 0);
+ assert_eq!(sender.status().active_shares, 0);
+}
+
+#[derive(Default)]
+struct ImportStartedGate {
+ state: Mutex,
+ changed: Condvar,
+}
+
+#[derive(Default)]
+struct ImportGateState {
+ blocked: bool,
+ released: bool,
+}
+
+impl ImportStartedGate {
+ fn wait_until_blocked(&self) {
+ let state = self.state.lock().unwrap();
+ let (state, _) = self
+ .changed
+ .wait_timeout_while(state, Duration::from_secs(5), |state| !state.blocked)
+ .unwrap();
+ assert!(state.blocked, "import did not reach the start gate");
+ }
+
+ fn release(&self) {
+ let mut state = self.state.lock().unwrap();
+ state.released = true;
+ self.changed.notify_all();
+ }
+}
+
+impl CoreEventSink for ImportStartedGate {
+ fn on_event(&self, event: CoreEvent) {
+ if event.phase != "import" || event.kind != "started" {
+ return;
+ }
+ let mut state = self.state.lock().unwrap();
+ state.blocked = true;
+ self.changed.notify_all();
+ while !state.released {
+ state = self.changed.wait(state).unwrap();
+ }
+ }
}
diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts
index deeb310..75fa1f5 100644
--- a/desktopApp/build.gradle.kts
+++ b/desktopApp/build.gradle.kts
@@ -6,6 +6,20 @@ plugins {
alias(libs.plugins.composeCompiler)
}
+val appVersion = providers.gradleProperty("vnidrop.version").get()
+val appVersionParts = appVersion.split(".")
+require(
+ appVersionParts.size == 3 &&
+ appVersionParts.mapIndexed { index, part ->
+ val number = part.toIntOrNull()
+ number != null &&
+ number.toString() == part &&
+ number in (if (index == 0) 1 else 0)..65535
+ }.all { it },
+) {
+ "vnidrop.version must be MAJOR.MINOR.PATCH with numeric components from 0 to 65535 and a non-zero major"
+}
+
dependencies {
implementation(projects.shared)
@@ -20,19 +34,28 @@ dependencies {
compose.desktop {
application {
mainClass = "com.vnidrop.app.MainKt"
+ buildTypes.release.proguard.isEnabled.set(false)
nativeDistributions {
- targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
- packageName = "com.vnidrop.app"
- packageVersion = "1.0.0"
+ targetFormats(TargetFormat.Dmg, TargetFormat.Deb, TargetFormat.Rpm)
+ packageName = "VniDrop"
+ packageVersion = appVersion
+ description = "Send files directly across your devices"
+ vendor = "Sudosy Labs"
+ licenseFile.set(project.file("../LICENSE"))
macOS {
+ bundleID = "com.vnidrop.app"
iconFile.set(project.file("../assets/macos/app-icon.icns"))
}
windows {
iconFile.set(project.file("../assets/windows/app-icon.ico"))
}
linux {
+ packageName = "vnidrop"
iconFile.set(project.file("../assets/linux/app-icon.png"))
+ debMaintainer = "support@sudosy.fr"
+ appRelease = "1"
+ rpmLicenseType = "Apache-2.0"
}
fileAssociation(
mimeType = "application/vnd.vnidrop.transfer",
diff --git a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt
index 585b8d0..b9a56b6 100644
--- a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt
+++ b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt
@@ -77,7 +77,7 @@ fun main(args: Array) {
Window(
onCloseRequest = ::exitApplication,
state = windowState,
- title = "vnidrop",
+ title = "VniDrop",
// Compose keeps edge resizers active for this client-decorated Linux window.
undecorated = linux,
) {
@@ -166,7 +166,7 @@ private fun WindowScope.MacOsTitleBar() {
) {
Box(modifier = Modifier.fillMaxSize().padding(end = MacOsTrafficLightsWidth)) {
BasicText(
- text = "vnidrop",
+ text = "VniDrop",
modifier = Modifier.align(Alignment.Center),
style = TextStyle(
color = colors.foregroundDefault,
@@ -195,7 +195,7 @@ private fun WindowScope.LinuxTitleBar(
.background(colors.backgroundSurface200),
) {
BasicText(
- text = "vnidrop",
+ text = "VniDrop",
modifier = Modifier.align(Alignment.Center),
style = TextStyle(
color = colors.foregroundDefault,
diff --git a/docs/.gitignore b/docs/.gitignore
new file mode 100644
index 0000000..d422c86
--- /dev/null
+++ b/docs/.gitignore
@@ -0,0 +1,10 @@
+.next/
+out/
+.vercel/
+*.tsbuildinfo
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+.env*
+!.env.example
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..ef71cba
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,26 @@
+# VniDrop website
+
+The product website for VniDrop, built with Next.js and exported as a static site.
+
+## Local development
+
+```bash
+npm install
+npm run dev
+```
+
+Open [http://localhost:3000](http://localhost:3000).
+
+## Checks
+
+```bash
+npm run lint
+npm run typecheck
+npm run build
+```
+
+The production build is written to `out/` and can be hosted by any static web server.
+
+Set `NEXT_PUBLIC_SITE_URL` to the canonical production origin when building for deployment so
+Open Graph and Twitter image URLs resolve to the public site. Vercel deployment URLs are detected
+automatically.
diff --git a/docs/app/base.css b/docs/app/base.css
new file mode 100644
index 0000000..aad950a
--- /dev/null
+++ b/docs/app/base.css
@@ -0,0 +1,179 @@
+:root {
+ --brand-700: #6f27e9;
+ --brand-600: #842fee;
+ --brand-500: #a855f7;
+ --brand-400: #c084fc;
+ --brand-300: #d8b4fe;
+ --ink: #171419;
+ --ink-soft: #4f4854;
+ --ink-muted: #786f7d;
+ --paper: #fbfafc;
+ --paper-warm: #f7f4f8;
+ --surface: #ffffff;
+ --line: #e5dfe8;
+ --line-strong: #d4cad8;
+ --dark-line: #362c3c;
+ --warning: #f59e0b;
+ --font-sans: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ --font-display: "Avenir Next", Avenir, "Segoe UI", ui-sans-serif, sans-serif;
+ --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
+ --shell: 1220px;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html {
+ scroll-behavior: smooth;
+ scroll-padding-top: 92px;
+}
+
+body {
+ margin: 0;
+ overflow-x: hidden;
+ background: var(--paper);
+ color: var(--ink);
+ font-family: var(--font-sans);
+ font-size: 16px;
+ line-height: 1.6;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+}
+
+::selection {
+ background: var(--brand-300);
+ color: #28132f;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+button,
+input,
+textarea,
+select {
+ font: inherit;
+}
+
+button,
+a {
+ -webkit-tap-highlight-color: transparent;
+}
+
+button {
+ color: inherit;
+}
+
+svg {
+ display: block;
+}
+
+h1,
+h2,
+h3,
+p {
+ margin-top: 0;
+}
+
+h1,
+h2,
+h3 {
+ font-family: var(--font-display);
+ text-wrap: balance;
+}
+
+p {
+ text-wrap: pretty;
+}
+
+:focus-visible {
+ outline: 3px solid rgba(168, 85, 247, 0.55);
+ outline-offset: 3px;
+}
+
+.page-shell {
+ width: min(calc(100% - 48px), var(--shell));
+ margin-inline: auto;
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.skip-link {
+ position: fixed;
+ z-index: 1000;
+ top: 12px;
+ left: 12px;
+ transform: translateY(-140%);
+ padding: 10px 16px;
+ border-radius: 10px;
+ background: var(--ink);
+ color: white;
+ font-weight: 700;
+ transition: transform 180ms ease;
+}
+
+.skip-link:focus {
+ transform: translateY(0);
+}
+
+.button {
+ display: inline-flex;
+ min-height: 52px;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ padding: 0 21px;
+ border: 1px solid transparent;
+ border-radius: 14px;
+ font-size: 14px;
+ font-weight: 730;
+ line-height: 1;
+ cursor: pointer;
+ transition: transform 180ms ease, box-shadow 180ms ease, background 180ms ease,
+ border-color 180ms ease;
+}
+
+.button:hover {
+ transform: translateY(-2px);
+}
+
+.button svg {
+ width: 18px;
+ height: 18px;
+}
+
+.button-primary {
+ background: linear-gradient(135deg, var(--brand-500), var(--brand-700));
+ box-shadow: 0 12px 28px rgba(132, 47, 238, 0.24), inset 0 1px rgba(255, 255, 255, 0.35);
+ color: white;
+}
+
+.button-primary:hover {
+ box-shadow: 0 16px 34px rgba(132, 47, 238, 0.3), inset 0 1px rgba(255, 255, 255, 0.35);
+}
+
+.motion-ready .reveal {
+ opacity: 0;
+ transform: translateY(28px);
+ transition: opacity 700ms cubic-bezier(0.2, 0.75, 0.25, 1),
+ transform 700ms cubic-bezier(0.2, 0.75, 0.25, 1);
+ transition-delay: var(--reveal-delay, 0ms);
+}
+
+.motion-ready .reveal.is-visible {
+ opacity: 1;
+ transform: translateY(0);
+}
diff --git a/docs/app/footer.css b/docs/app/footer.css
new file mode 100644
index 0000000..7eadcc6
--- /dev/null
+++ b/docs/app/footer.css
@@ -0,0 +1,66 @@
+.site-footer {
+ padding-top: 42px;
+ background: var(--paper);
+}
+
+.footer-inner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 40px;
+ padding-bottom: 38px;
+}
+
+.footer-identity {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ color: var(--ink);
+ font-family: var(--font-display);
+ font-size: 18px;
+ font-weight: 750;
+ letter-spacing: -0.04em;
+}
+
+.footer-identity img {
+ width: 38px;
+ height: 38px;
+}
+
+.footer-links {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+}
+
+.footer-links a {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--ink-soft);
+ font-size: 12px;
+ font-weight: 620;
+}
+
+.footer-links a:hover {
+ color: var(--brand-600);
+}
+
+.footer-links svg {
+ width: 14px;
+}
+
+.footer-bottom {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding-top: 20px;
+ padding-bottom: 26px;
+ border-top: 1px solid var(--line);
+ color: var(--ink-muted);
+ font-size: 11px;
+}
+
+.footer-bottom p {
+ margin: 0;
+}
diff --git a/docs/app/home.css b/docs/app/home.css
new file mode 100644
index 0000000..57e8c02
--- /dev/null
+++ b/docs/app/home.css
@@ -0,0 +1,358 @@
+.hero-section {
+ position: relative;
+ min-height: 850px;
+ overflow: hidden;
+ padding: 150px 0 86px;
+ background:
+ radial-gradient(circle at 82% 18%, rgba(211, 165, 255, 0.24), transparent 28%),
+ radial-gradient(circle at 12% 76%, rgba(184, 108, 255, 0.12), transparent 25%),
+ linear-gradient(180deg, #fdfcfe 0%, #faf7fb 100%);
+}
+
+.hero-section::before {
+ position: absolute;
+ inset: 0;
+ background-image: radial-gradient(rgba(93, 71, 102, 0.1) 0.7px, transparent 0.7px);
+ background-size: 19px 19px;
+ content: "";
+ mask-image: linear-gradient(to bottom, black, transparent 82%);
+ opacity: 0.38;
+}
+
+.hero-ambient {
+ position: absolute;
+ border: 1px solid rgba(168, 85, 247, 0.18);
+ border-radius: 999px;
+ pointer-events: none;
+}
+
+.hero-ambient-one {
+ top: 120px;
+ right: -80px;
+ width: 420px;
+ height: 420px;
+ animation: ambient-drift 15s ease-in-out infinite alternate;
+}
+
+.hero-ambient-two {
+ bottom: -190px;
+ left: -120px;
+ width: 400px;
+ height: 400px;
+ animation: ambient-drift 18s ease-in-out -5s infinite alternate-reverse;
+}
+
+.hero-layout {
+ position: relative;
+ z-index: 1;
+ display: grid;
+ grid-template-columns: minmax(0, 0.88fr) minmax(540px, 1.12fr);
+ align-items: center;
+ gap: 30px;
+}
+
+.hero-copy {
+ position: relative;
+ z-index: 3;
+ padding-top: 12px;
+}
+
+.eyebrow-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 9px;
+ margin-bottom: 25px;
+ padding: 7px 11px;
+ border: 1px solid #dfd4e4;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.72);
+ color: #665c6b;
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+}
+
+.eyebrow-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--warning);
+ box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.13);
+}
+
+.hero-copy h1 {
+ margin-bottom: 28px;
+ font-size: clamp(3.5rem, 5.1vw, 5.4rem);
+ font-weight: 760;
+ letter-spacing: -0.072em;
+ line-height: 0.98;
+}
+
+.hero-copy h1 span {
+ background: linear-gradient(100deg, var(--brand-600), #ba62f8 58%, #7c2aef);
+ background-clip: text;
+ -webkit-background-clip: text;
+ color: transparent;
+}
+
+.hero-lead {
+ max-width: 590px;
+ margin-bottom: 30px;
+ color: var(--ink-soft);
+ font-size: 18px;
+ line-height: 1.7;
+}
+
+.hero-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 11px;
+}
+
+.hero-platforms {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 14px;
+ margin-top: 35px;
+ color: var(--ink-muted);
+ font-size: 12px;
+}
+
+.hero-platforms > span {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.hero-platforms ul {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 7px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.hero-platforms li {
+ display: grid;
+ width: 36px;
+ height: 36px;
+ place-items: center;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.78);
+ box-shadow: 0 7px 18px rgba(55, 35, 63, 0.055);
+}
+
+.hero-platforms .platform-glyph {
+ width: 20px;
+ height: 20px;
+}
+
+.platform-glyph[data-platform="android"] { color: #2fbf71; }
+.platform-glyph[data-platform="ios"] { color: #201c23; }
+.platform-glyph[data-platform="macos"] { color: #1687f8; }
+.platform-glyph[data-platform="windows"] { color: #008de5; }
+
+.hero-art-reveal {
+ min-width: 0;
+}
+
+.hero-app-preview {
+ position: relative;
+ width: min(112%, 850px);
+ margin: 0 0 0 -3%;
+ isolation: isolate;
+}
+
+.hero-app-preview::before {
+ position: absolute;
+ z-index: -1;
+ inset: 14% 8% 8% 10%;
+ border-radius: 48%;
+ background: rgba(168, 85, 247, 0.2);
+ filter: blur(70px);
+ content: "";
+}
+
+.hero-app-preview-image {
+ display: block;
+ width: 100%;
+ height: auto;
+ filter: drop-shadow(0 28px 42px rgba(36, 24, 42, 0.2));
+}
+
+.simple-section {
+ padding: 124px 0;
+}
+
+.simple-heading {
+ max-width: 760px;
+ margin-bottom: 58px;
+}
+
+.kicker {
+ display: inline-block;
+ margin-bottom: 17px;
+ color: var(--brand-600);
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 800;
+ letter-spacing: 0.13em;
+}
+
+.kicker-light {
+ color: #d89bff;
+}
+
+.simple-heading h2,
+.trust-summary-copy h2 {
+ margin-bottom: 18px;
+ font-size: clamp(2.6rem, 4.2vw, 4.6rem);
+ font-weight: 740;
+ letter-spacing: -0.06em;
+ line-height: 1.02;
+}
+
+.simple-heading p {
+ max-width: 650px;
+ margin: 0;
+ color: var(--ink-soft);
+ font-size: 17px;
+ line-height: 1.7;
+}
+
+.simple-steps {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ border-top: 1px solid var(--line);
+ border-bottom: 1px solid var(--line);
+}
+
+.simple-step {
+ min-height: 235px;
+ padding: 30px 30px 36px;
+}
+
+.simple-step + .simple-step {
+ border-left: 1px solid var(--line);
+}
+
+.simple-step-top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 46px;
+}
+
+.simple-step-top span {
+ color: var(--brand-600);
+ font-family: var(--font-mono);
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.simple-step-top svg {
+ width: 24px;
+ height: 24px;
+ color: var(--brand-500);
+}
+
+.simple-step h3 {
+ margin-bottom: 10px;
+ font-size: 22px;
+ letter-spacing: -0.035em;
+}
+
+.simple-step p {
+ max-width: 310px;
+ margin: 0;
+ color: var(--ink-muted);
+ font-size: 14px;
+ line-height: 1.65;
+}
+
+.trust-summary-section {
+ padding: 112px 0;
+ background: #171319;
+ color: white;
+}
+
+.trust-summary {
+ display: grid;
+ grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr);
+ align-items: start;
+ gap: 100px;
+}
+
+.trust-summary-copy {
+ max-width: 520px;
+}
+
+.trust-summary-copy p {
+ margin-bottom: 28px;
+ color: #b7adb9;
+ font-size: 16px;
+ line-height: 1.72;
+}
+
+.simple-text-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 9px;
+ color: #ddaaff;
+ font-size: 14px;
+ font-weight: 720;
+}
+
+.simple-text-link svg {
+ width: 17px;
+}
+
+.simple-text-link:hover {
+ color: white;
+}
+
+.trust-summary-list {
+ border-top: 1px solid var(--dark-line);
+}
+
+.trust-summary-item {
+ display: grid;
+ grid-template-columns: 44px 1fr;
+ gap: 18px;
+ padding: 24px 0;
+ border-bottom: 1px solid var(--dark-line);
+}
+
+.trust-summary-item > span {
+ display: grid;
+ width: 42px;
+ height: 42px;
+ place-items: center;
+ border: 1px solid rgba(192, 132, 252, 0.24);
+ border-radius: 12px;
+ color: var(--brand-400);
+}
+
+.trust-summary-item svg {
+ width: 21px;
+}
+
+.trust-summary-item h3 {
+ margin-bottom: 6px;
+ font-size: 18px;
+ letter-spacing: -0.025em;
+}
+
+.trust-summary-item p {
+ max-width: 530px;
+ margin: 0;
+ color: #a99fac;
+ font-size: 13px;
+ line-height: 1.62;
+}
diff --git a/docs/app/icon.svg b/docs/app/icon.svg
new file mode 100644
index 0000000..f8eb118
--- /dev/null
+++ b/docs/app/icon.svg
@@ -0,0 +1,29 @@
+
diff --git a/docs/app/layout.tsx b/docs/app/layout.tsx
new file mode 100644
index 0000000..7e52e0e
--- /dev/null
+++ b/docs/app/layout.tsx
@@ -0,0 +1,90 @@
+import type { Metadata, Viewport } from "next";
+import type { ReactNode } from "react";
+import { SiteFooter } from "@/components/site-footer";
+import { SiteHeader } from "@/components/site-header";
+import "./base.css";
+import "../components/header.css";
+import "./home.css";
+import "./footer.css";
+import "./privacy/privacy-document.css";
+import "./responsive.css";
+import "./privacy/privacy-responsive.css";
+
+const configuredSiteUrl =
+ process.env.NEXT_PUBLIC_SITE_URL ??
+ process.env.VERCEL_PROJECT_PRODUCTION_URL ??
+ process.env.VERCEL_URL ??
+ "http://localhost:3000";
+
+const metadataBase = new URL(
+ configuredSiteUrl.startsWith("http") ? configuredSiteUrl : `https://${configuredSiteUrl}`,
+);
+
+export const metadata: Metadata = {
+ metadataBase,
+ title: {
+ default: "VniDrop — Direct file transfer, on your terms",
+ template: "%s · VniDrop",
+ },
+ description:
+ "Send files and folders directly across Android, iOS, macOS, Windows, and Linux—with approval by default and no hosted transfer copy.",
+ applicationName: "VniDrop",
+ manifest: "/site.webmanifest",
+ keywords: [
+ "peer-to-peer file transfer",
+ "encrypted file sharing",
+ "cross-platform file transfer",
+ "open source",
+ ],
+ openGraph: {
+ type: "website",
+ siteName: "VniDrop",
+ title: "VniDrop — Direct file transfer, on your terms",
+ description:
+ "Move files from your device to theirs, with no account and no hosted transfer copy.",
+ images: [
+ {
+ url: "/og.png",
+ width: 1200,
+ height: 630,
+ alt: "VniDrop — Your files, a straight line between devices.",
+ },
+ ],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: "VniDrop — Direct file transfer, on your terms",
+ description:
+ "Move files from your device to theirs, with no account and no hosted transfer copy.",
+ images: [
+ {
+ url: "/og.png",
+ alt: "VniDrop — Your files, a straight line between devices.",
+ },
+ ],
+ },
+};
+
+export const viewport: Viewport = {
+ width: "device-width",
+ initialScale: 1,
+ themeColor: [
+ { media: "(prefers-color-scheme: light)", color: "#fbfafc" },
+ { media: "(prefers-color-scheme: dark)", color: "#17131a" },
+ ],
+};
+
+export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) {
+ return (
+
+
+
+ Skip to content
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/docs/app/og.png/route.tsx b/docs/app/og.png/route.tsx
new file mode 100644
index 0000000..46b40aa
--- /dev/null
+++ b/docs/app/og.png/route.tsx
@@ -0,0 +1,424 @@
+import { ImageResponse } from "next/og";
+
+export const dynamic = "force-static";
+
+const imageSize = {
+ width: 1200,
+ height: 630,
+};
+
+function BrandMark() {
+ return (
+
+ );
+}
+
+function FileGlyph() {
+ return (
+
+ );
+}
+
+function CheckGlyph() {
+ return (
+
+ );
+}
+
+function LockGlyph() {
+ return (
+
+ );
+}
+
+function TransferIllustration() {
+ return (
+
+
+
+
+ DIRECT · ENCRYPTED
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Project files
+
12 items · 284 MB
+
+
+
+
+
+
+
Sending directly
+
72%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Received
+
Verified on arrival
+
+
+
+ );
+}
+
+export function GET() {
+ return new ImageResponse(
+
+
+
+
+
+
+
+
+
+
+
+
VniDrop
+
+ OPEN SOURCE · LOCAL P2P
+
+
+
+
+
+
Your files.
+
A straight line
+
between devices.
+
+
+
+ Send files directly. Stay in control.
+
+
+
+
,
+ imageSize,
+ );
+}
diff --git a/docs/app/page.tsx b/docs/app/page.tsx
new file mode 100644
index 0000000..93ab9b2
--- /dev/null
+++ b/docs/app/page.tsx
@@ -0,0 +1,169 @@
+import Image from "next/image";
+import Link from "next/link";
+import { Icon, type IconName } from "@/components/icons";
+import { PlatformIcon, supportedPlatforms } from "@/components/platform-icon";
+import { Reveal } from "@/components/reveal";
+
+const steps: Array<{
+ icon: IconName;
+ number: string;
+ title: string;
+ text: string;
+}> = [
+ {
+ icon: "folder",
+ number: "01",
+ title: "Choose what to send",
+ text: "Pick files, a batch, or a complete folder. VniDrop preserves the folder structure.",
+ },
+ {
+ icon: "qr",
+ number: "02",
+ title: "Share an invitation",
+ text: "Introduce the devices with a QR code, NFC tap, or portable .vnd invitation.",
+ },
+ {
+ icon: "shield",
+ number: "03",
+ title: "Approve and transfer",
+ text: "The receiver asks first. Once approved, the files move over an authenticated encrypted connection.",
+ },
+];
+
+const trustItems: Array<{
+ icon: IconName;
+ title: string;
+ text: string;
+}> = [
+ {
+ icon: "devices",
+ title: "Direct when possible",
+ text: "Devices connect directly when they can. An encrypted relay forwards traffic when they cannot.",
+ },
+ {
+ icon: "lock",
+ title: "No hosted copy",
+ text: "A relay is a route, not storage. Your transfer is never turned into a cloud download.",
+ },
+ {
+ icon: "verified",
+ title: "Verified on arrival",
+ text: "Content addressing checks that the received bytes match exactly what you sent.",
+ },
+];
+
+export default function HomePage() {
+ return (
+
+
+
+
+
+
+
+
+
+ OPEN SOURCE · EARLY DEVELOPMENT
+
+
+ Your files.
+
+ A straight line
+
+ between devices.
+
+
+ Send files and folders across your devices—direct when possible, private by design,
+ and always under your control.
+
+ This policy explains what moves between devices, what stays local, and what is sent
+ only when you choose to share diagnostics or a bug report.
+
+
Effective July 16, 2026 · Version 1.1
+
+
+
+
+
+
+
+
+
+ The short version
+
+ VniDrop has no user accounts and does not upload your transfer to a VniDrop file
+ store. Files travel over an authenticated, end-to-end encrypted connection.
+ Product diagnostics are opt-in; a bug report is sent only when you submit one.
+
+
+
+
+
Scope and who “VniDrop” means
+
+ This policy covers the official VniDrop website, the VniDrop applications for
+ Android, iOS, macOS, Windows, and Linux, and the diagnostics service configured by
+ the official project. For an official release, VniDrop’s data controller is the
+ individual publisher named in the applicable app-store listing. In this policy,
+ “VniDrop,” “we,” and “us” also include the maintainers acting on that publisher’s
+ behalf. The publisher can be reached at support@sudosy.fr.
+
+
+ VniDrop is open-source software. A build distributed or operated by someone else
+ may use different networking infrastructure, diagnostics settings, or website
+ hosting. That distributor is responsible for explaining its own practices.
+
+
+
+
+
What happens during a transfer
+
File contents
+
+ The sender chooses files or folders on their device. VniDrop streams those bytes to
+ an approved receiver and does not first upload them to a VniDrop-hosted storage
+ bucket. The receiver saves the files to a destination they choose. Relayed traffic
+ remains end-to-end encrypted.
+
+
Invitations and transfer metadata
+
+ A QR code, NFC tag, or .vnd file contains a transfer invitation. The
+ invitation includes connection and content identifiers plus transfer metadata such
+ as the transfer name, optional sender name, creation time, file count, and total
+ size. It is a capability: anyone who receives it may be able to request the transfer
+ while the share is active. Treat it like a private access link.
+
+
What peers and relays can see
+
+ A receive request can disclose the receiver’s chosen display or device name,
+ application version, and a technical endpoint identifier to the sender. A direct
+ connection exposes the peers’ IP addresses to one another. When a public relay is
+ used, its operator can observe connection metadata such as source and destination IP
+ addresses, connection time, and the amount of relayed data, but cannot read the
+ encrypted transfer contents.
+
+
+
+ Approval is required by default. If the sender selects “Anyone with this transfer,”
+ anyone holding the invitation may receive the files until sharing stops.
+
+
+
+
+
+
Information kept on your device
+
VniDrop stores the information needed to operate the app locally, including:
+
+
device identity and networking keys used to establish secure connections;
+
active shares, transfer history, receiver requests, progress, and status;
+
app preferences, including access and diagnostics choices;
+
download destinations and locally managed transfer data; and
+
an anonymous installation identifier used only for diagnostics correlation.
+
+
+ This information remains until you remove the relevant history, stop or delete a
+ share, clear the app’s data, or uninstall the app, subject to operating-system file
+ behavior. Removing VniDrop history does not delete a file you already downloaded;
+ delete that file through your operating system if you no longer want it.
+
+
+
+
+
Optional diagnostics and bug reports
+
Automatic product diagnostics
+
+ Official releases indicate in the app settings whether automatic product
+ diagnostics are included. When included, automatic usage events and crash reports
+ are disabled until you enable “Share diagnostics.” If enabled, VniDrop may send an
+ anonymous installation ID, app version, platform, sparse event names and properties,
+ crash type and message, a redacted stack trace, timestamps, and recent in-app
+ breadcrumbs. You can turn this off at any time; doing so also removes pending local
+ crash reports.
+
+
User-submitted bug reports
+
+ A bug report is separate from the diagnostics toggle and is sent only when you press
+ submit. It can contain what you say happened, what you expected, reproduction steps,
+ an optional contact email, app and platform versions, an anonymous installation ID,
+ device name and model, operating system, network and battery information, recent
+ breadcrumbs, and optional recent logs. You can exclude logs before submitting.
+
+
Data deliberately excluded
+
+ Automatic diagnostics are designed to exclude transfer contents, invitations, and
+ file paths. Before diagnostic text or optional logs are sent, VniDrop applies rules
+ intended to redact invitation tokens, endpoint identifiers, absolute paths, file and
+ content URIs, and platform document identifiers. No redaction system is perfect, so
+ review anything you type into a bug report and avoid including secrets.
+
+
+
+
+
The VniDrop website
+
+ This website is a static product site. It does not provide an account, contact form,
+ advertising, behavioral analytics, marketing pixels, or non-essential cookies. It
+ does not ask the browser for access to your files, camera, contacts, location, or
+ nearby devices.
+
+
+ Vercel hosts the static site, while Cloudflare proxies requests and provides DNS and
+ security services for the domain. They may process routine request information—such
+ as IP address, time, requested page, referrer, and browser user agent—to deliver the
+ site, maintain reliability, and prevent abuse.
+
+
+
+
+
Device permissions
+
+
+
Files & folders
+
Choose what to send and where received files are saved.
+
+
+
Camera / scanner
+
Scan a QR invitation when you choose that receive method.
+
+
+
NFC
+
Read or write an invitation through a compatible NFC tag.
+
+
+
Network & notifications
+
Connect peers and alert you to background receiver requests.
+
+
+
+ VniDrop requests a platform permission only for the related feature. On Android, QR
+ scanning may be provided through Google Play services Code Scanner. Platform-level
+ permission prompts and service-provider terms also apply.
+
+
+
+
+
Infrastructure and external services
+
+
+
Iroh / public relay operators
+
+ Device discovery, connection establishment, and encrypted relay fallback.
+ Relays process connection metadata but cannot decrypt transfer contents.
+
+
+
+
Vercel
+
+ Hosts and serves the static VniDrop website and processes routine request and
+ delivery metadata.
+
+
+
+
Cloudflare
+
+ Proxies website requests and provides DNS, security, and abuse controls. When
+ the optional diagnostics service is configured, it uses Cloudflare Workers, D1,
+ and R2.
+
+
+
+
Google Play services
+
+ May provide the QR code scanner on supported Android devices when you choose to
+ scan an invitation.
+
+
+
+
GitHub
+
+ Hosts the source repository, issue tracker, and external pages linked from this
+ site. GitHub’s own privacy terms apply after you follow those links.
+
Until you delete them, clear app data, or uninstall
+
+
+
Pending local crash reports
+
Up to 30 days and 20 reports; deleted when diagnostics is disabled
+
+
+
Server diagnostics and bug reports
+
The current project configuration is 90 days, with scheduled deletion
+
+
+
Downloaded files
+
Until you delete them through your operating system
+
+
+
+
+
+ Operational backups, provider logs, and deletion backlogs may persist briefly beyond
+ the stated period where necessary for security, integrity, or legal obligations. If
+ the production diagnostics retention configuration changes, this policy should be
+ updated to match it.
+
+
+
+
+
Your choices and rights
+
+
Enable or disable “Share diagnostics” in VniDrop settings.
+
+ Submit a bug report only when you choose, omit contact information, and exclude
+ logs.
+
+
Approve or refuse each receiver, cancel a transfer, or stop sharing.
+
+ Delete individual transfer history or clear completed, failed, and cancelled
+ receive history.
+
+
+ Delete downloaded files using your operating system, or clear all app data by
+ uninstalling or resetting the app.
+
+
+
+ Depending on where you live, privacy law may provide rights to access, correct,
+ delete, restrict, or object to processing of personal information. Because VniDrop
+ has no account and automatic diagnostics use an anonymous installation ID, we may
+ not be able to connect a server record to you without additional information. Use
+ the contact method below and provide only what is needed to locate your submission.
+
+
+
+
+
Security
+
+ VniDrop uses authenticated end-to-end encrypted connections, content verification,
+ deny-by-default share access, bounded diagnostics payloads, redaction, and safe file
+ publishing that avoids silently replacing an existing file. No system can guarantee
+ absolute security. Keep invitations private, verify receiver names, keep your device
+ updated, and stop sharing when a transfer is finished.
+
+
+ Please report a suspected vulnerability through the private process in the{" "}
+
+ VniDrop security policy
+
+ , not in a public issue.
+
+
+
+
+
Changes to this policy
+
+ VniDrop is in early development. Features and data practices may change. When this
+ policy changes, we will update the effective date and version at the top of the page
+ and publish the revised text with the project. Material changes should be called out
+ in release notes or the application where practical.
+
+
+
+
+
Contact
+
+ For a privacy question, rights request, or support request, email
+ support@sudosy.fr. Do not put an invitation, file content, credentials, or other
+ sensitive information in a public issue.
+