Merge with master

This commit is contained in:
2026-07-18 11:57:55 +02:00
69 changed files with 11203 additions and 102 deletions

47
.github/workflows/docs.yml vendored Normal file
View File

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

344
.github/workflows/linux-packages.yml vendored Normal file
View File

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

157
.github/workflows/windows-store.yml vendored Normal file
View File

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

1
.gitignore vendored
View File

@@ -22,3 +22,4 @@ target/
# Local design export scratch
output/
.screenshots

161
CODE_OF_CONDUCT.md Normal file
View File

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

167
CONTRIBUTING.md Normal file
View File

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

202
LICENSE Normal file
View File

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

159
README.md
View File

@@ -1,36 +1,143 @@
This is a Kotlin Multiplatform project targeting Android, iOS, Desktop (JVM).
<p align="center">
<img src="assets/1024x1024.png" alt="VniDrop app icon" width="128" />
</p>
* [/iosApp](./iosApp/iosApp) contains an iOS application. Even if youre 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.
<h1 align="center">VniDrop</h1>
* [/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 thats 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 Apples 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.
<p align="center">
<strong>Send files directly. Stay in control of who receives them.</strong>
</p>
### Running the apps
<p align="center">
Cross-platform file transfer for Android, iOS, macOS, Windows, and Linux.
</p>
Use the run configurations provided by the run widget in your IDE's toolbar. You can also use these commands and
options:
<p align="center">
<a href="https://github.com/vnidrop/vnidrop/actions/workflows/rust-core.yml"><img src="https://github.com/vnidrop/vnidrop/actions/workflows/rust-core.yml/badge.svg" alt="Rust core status" /></a>
<a href="https://github.com/vnidrop/vnidrop/actions/workflows/shared-kmp.yml"><img src="https://github.com/vnidrop/vnidrop/actions/workflows/shared-kmp.yml/badge.svg" alt="Shared KMP status" /></a>
<img src="https://img.shields.io/badge/status-early%20development-F59E0B" alt="Early development" />
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-6D28D9" alt="Apache 2.0 license" /></a>
</p>
- 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).

102
SECURITY.md Normal file
View File

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

View File

@@ -1,3 +1,3 @@
<resources>
<string name="app_name">vnidrop</string>
<string name="app_name">VniDrop</string>
</resources>

View File

@@ -2,6 +2,7 @@
name = "vnidrop"
version = "0.1.0"
edition = "2021"
license = "Apache-2.0"
[lib]
name = "vnidrop"

View File

@@ -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<ImportGateState>,
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();
}
}
}

View File

@@ -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",

View File

@@ -77,7 +77,7 @@ fun main(args: Array<String>) {
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,

10
docs/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.next/
out/
.vercel/
*.tsbuildinfo
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.env*
!.env.example

26
docs/README.md Normal file
View File

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

179
docs/app/base.css Normal file
View File

@@ -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);
}

66
docs/app/footer.css Normal file
View File

@@ -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;
}

358
docs/app/home.css Normal file
View File

@@ -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;
}

29
docs/app/icon.svg Normal file
View File

@@ -0,0 +1,29 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="40" y="40" width="944" height="944" rx="210" fill="#FFFFFF" stroke="#E9E7F0" stroke-width="8"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.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.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="url(#paint0_linear_11_12)"/>
<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" fill="url(#paint1_linear_11_12)"/>
<mask id="mask0_11_12" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="176" y="148" width="671" height="732">
<path d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.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.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="white"/>
</mask>
<g mask="url(#mask0_11_12)">
<path d="M688 148H782C832 148 842 171.8 847.48 210V303.68L688 148Z" fill="url(#paint2_linear_11_12)"/>
</g>
<defs>
<linearGradient id="paint0_linear_11_12" x1="176" y1="148" x2="904.706" y2="816.252" gradientUnits="userSpaceOnUse">
<stop stop-color="#A855F7"/>
<stop offset="0.48" stop-color="#9D4DF4"/>
<stop offset="1" stop-color="#7C2AEF"/>
</linearGradient>
<linearGradient id="paint1_linear_11_12" x1="404.439" y1="431.72" x2="707.314" y2="649.286" gradientUnits="userSpaceOnUse">
<stop stop-color="#A855F7"/>
<stop offset="0.48" stop-color="#9D4DF4"/>
<stop offset="1" stop-color="#7C2AEF"/>
</linearGradient>
<linearGradient id="paint2_linear_11_12" x1="683.48" y1="144.6" x2="793.636" y2="306.833" gradientUnits="userSpaceOnUse">
<stop stop-color="#F2DDFF"/>
<stop offset="1" stop-color="#C084FC"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

90
docs/app/layout.tsx Normal file
View File

@@ -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 (
<html lang="en">
<body>
<a className="skip-link" href="#main-content">
Skip to content
</a>
<SiteHeader />
{children}
<SiteFooter />
</body>
</html>
);
}

424
docs/app/og.png/route.tsx Normal file
View File

@@ -0,0 +1,424 @@
import { ImageResponse } from "next/og";
export const dynamic = "force-static";
const imageSize = {
width: 1200,
height: 630,
};
function BrandMark() {
return (
<svg width="54" height="54" viewBox="0 0 1024 1024" fill="none">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.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.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z"
fill="url(#brand-gradient)"
/>
<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"
fill="url(#drop-gradient)"
/>
<defs>
<linearGradient id="brand-gradient" x1="176" y1="148" x2="905" y2="816">
<stop stopColor="#C084FC" />
<stop offset="0.52" stopColor="#A855F7" />
<stop offset="1" stopColor="#7C2AEF" />
</linearGradient>
<linearGradient id="drop-gradient" x1="404" y1="432" x2="707" y2="649">
<stop stopColor="#E9D5FF" />
<stop offset="1" stopColor="#A855F7" />
</linearGradient>
</defs>
</svg>
);
}
function FileGlyph() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M6 3h8l4 4v14H6z"
stroke="currentColor"
strokeWidth="1.7"
strokeLinejoin="round"
/>
<path d="M14 3v5h4M9 13h6M9 17h4" stroke="currentColor" strokeWidth="1.7" />
</svg>
);
}
function CheckGlyph() {
return (
<svg width="27" height="27" viewBox="0 0 24 24" fill="none">
<path
d="m6.5 12.5 3.4 3.4 7.8-8"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function LockGlyph() {
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<rect x="5" y="10" width="14" height="11" rx="3" stroke="currentColor" strokeWidth="1.8" />
<path d="M8 10V7a4 4 0 0 1 8 0v3M12 14v3" stroke="currentColor" strokeWidth="1.8" />
</svg>
);
}
function TransferIllustration() {
return (
<div
style={{
position: "absolute",
top: 86,
right: 72,
width: 470,
height: 474,
display: "flex",
}}
>
<div
style={{
position: "absolute",
top: 24,
left: 48,
width: 400,
height: 400,
borderRadius: 999,
display: "flex",
background:
"radial-gradient(circle, rgba(168, 85, 247, 0.24) 0%, rgba(124, 42, 239, 0.09) 44%, rgba(13, 10, 16, 0) 72%)",
}}
/>
<div
style={{
position: "absolute",
top: 0,
right: 2,
display: "flex",
alignItems: "center",
color: "#A99EAD",
fontSize: 12,
fontWeight: 700,
letterSpacing: 2.1,
}}
>
DIRECT · ENCRYPTED
</div>
<svg
width="470"
height="474"
viewBox="0 0 470 474"
fill="none"
style={{ position: "absolute", inset: 0 }}
>
<path
d="M264 229C305 233 310 300 353 313"
stroke="url(#route-gradient)"
strokeWidth="3"
strokeLinecap="round"
strokeDasharray="8 9"
/>
<circle cx="264" cy="229" r="6" fill="#D8B4FE" />
<circle cx="353" cy="313" r="6" fill="#A855F7" />
<defs>
<linearGradient id="route-gradient" x1="264" y1="229" x2="353" y2="313">
<stop stopColor="#D8B4FE" />
<stop offset="1" stopColor="#7C2AEF" />
</linearGradient>
</defs>
</svg>
<div
style={{
position: "absolute",
top: 79,
left: 1,
width: 286,
height: 184,
display: "flex",
flexDirection: "column",
border: "2px solid #44384A",
borderRadius: 18,
background: "#18131D",
boxShadow: "0 24px 70px rgba(0, 0, 0, 0.38)",
overflow: "hidden",
}}
>
<div
style={{
height: 34,
display: "flex",
alignItems: "center",
padding: "0 14px",
borderBottom: "1px solid #382E3D",
}}
>
<div style={{ width: 7, height: 7, borderRadius: 99, background: "#685B6E", display: "flex" }} />
<div style={{ width: 7, height: 7, borderRadius: 99, background: "#685B6E", display: "flex", marginLeft: 7 }} />
<div style={{ width: 7, height: 7, borderRadius: 99, background: "#A855F7", display: "flex", marginLeft: 7 }} />
</div>
<div
style={{
display: "flex",
flexDirection: "column",
padding: "20px 21px",
}}
>
<div style={{ display: "flex", alignItems: "center" }}>
<div
style={{
width: 42,
height: 42,
display: "flex",
alignItems: "center",
justifyContent: "center",
border: "1px solid #4C3A57",
borderRadius: 11,
background: "#241A2B",
color: "#C084FC",
}}
>
<FileGlyph />
</div>
<div style={{ display: "flex", flexDirection: "column", marginLeft: 13 }}>
<div style={{ color: "#F8F4FA", fontSize: 16, fontWeight: 650 }}>Project files</div>
<div style={{ color: "#8E8292", fontSize: 12, marginTop: 3 }}>12 items · 284 MB</div>
</div>
</div>
<div
style={{
width: "100%",
height: 5,
display: "flex",
marginTop: 21,
borderRadius: 99,
background: "#342A39",
overflow: "hidden",
}}
>
<div
style={{
width: "72%",
height: "100%",
display: "flex",
borderRadius: 99,
background: "linear-gradient(90deg, #C084FC, #7C2AEF)",
}}
/>
</div>
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 10 }}>
<div style={{ color: "#A99EAD", fontSize: 11 }}>Sending directly</div>
<div style={{ color: "#D8B4FE", fontSize: 11, fontWeight: 700 }}>72%</div>
</div>
</div>
</div>
<div
style={{
position: "absolute",
top: 263,
left: 112,
width: 62,
height: 8,
display: "flex",
borderRadius: 99,
background: "#44384A",
}}
/>
<div
style={{
position: "absolute",
top: 270,
left: 87,
width: 112,
height: 8,
display: "flex",
borderRadius: 99,
background: "#29212E",
}}
/>
<div
style={{
position: "absolute",
top: 226,
left: 286,
width: 50,
height: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
border: "1px solid #6A477B",
borderRadius: 15,
background: "#211627",
color: "#D8B4FE",
boxShadow: "0 12px 32px rgba(124, 42, 239, 0.3)",
}}
>
<LockGlyph />
</div>
<div
style={{
position: "absolute",
top: 238,
right: 6,
width: 127,
height: 230,
display: "flex",
flexDirection: "column",
alignItems: "center",
border: "2px solid #51415A",
borderRadius: 30,
background: "#18131D",
boxShadow: "0 24px 70px rgba(0, 0, 0, 0.42)",
overflow: "hidden",
}}
>
<div
style={{
width: 48,
height: 5,
display: "flex",
marginTop: 10,
borderRadius: 99,
background: "#45374C",
}}
/>
<div
style={{
width: 58,
height: 58,
display: "flex",
alignItems: "center",
justifyContent: "center",
marginTop: 38,
border: "1px solid #6F4A82",
borderRadius: 99,
background: "#27182E",
color: "#D8B4FE",
}}
>
<CheckGlyph />
</div>
<div style={{ color: "#F8F4FA", fontSize: 15, fontWeight: 700, marginTop: 15 }}>Received</div>
<div style={{ color: "#8E8292", fontSize: 10, marginTop: 5 }}>Verified on arrival</div>
<div
style={{
width: 46,
height: 4,
display: "flex",
marginTop: 32,
borderRadius: 99,
background: "#45374C",
}}
/>
</div>
</div>
);
}
export function GET() {
return new ImageResponse(
<div
style={{
position: "relative",
width: "100%",
height: "100%",
display: "flex",
overflow: "hidden",
background: "#0D0A10",
color: "#FAF7FC",
fontFamily: "sans-serif",
}}
>
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
background:
"radial-gradient(circle at 84% 48%, rgba(168, 85, 247, 0.14), transparent 34%), radial-gradient(circle at 16% 100%, rgba(124, 42, 239, 0.09), transparent 30%)",
}}
/>
<div style={{ position: "absolute", top: 64, bottom: 64, left: 64, display: "flex", borderLeft: "1px dashed #3A3040" }} />
<div style={{ position: "absolute", top: 64, bottom: 64, right: 64, display: "flex", borderRight: "1px dashed #3A3040" }} />
<div style={{ position: "absolute", top: 64, right: 0, left: 0, display: "flex", borderTop: "1px solid #3A3040" }} />
<div style={{ position: "absolute", right: 0, bottom: 64, left: 0, display: "flex", borderBottom: "1px solid #3A3040" }} />
<div
style={{
position: "absolute",
top: 89,
left: 96,
display: "flex",
alignItems: "center",
}}
>
<BrandMark />
<div style={{ display: "flex", alignItems: "baseline", marginLeft: 12 }}>
<div style={{ fontSize: 28, fontWeight: 750, letterSpacing: -1.1 }}>VniDrop</div>
<div
style={{
marginLeft: 18,
color: "#8E8292",
fontSize: 11,
fontWeight: 700,
letterSpacing: 1.8,
}}
>
OPEN SOURCE · LOCAL P2P
</div>
</div>
</div>
<div
style={{
position: "absolute",
top: 185,
left: 96,
width: 625,
display: "flex",
flexDirection: "column",
fontSize: 66,
fontWeight: 750,
lineHeight: 0.99,
letterSpacing: -3.4,
}}
>
<div style={{ display: "flex" }}>Your files.</div>
<div style={{ display: "flex", color: "#C084FC", marginTop: 3 }}>A straight line</div>
<div style={{ display: "flex", marginTop: 3 }}>between devices.</div>
</div>
<div
style={{
position: "absolute",
bottom: 101,
left: 96,
display: "flex",
color: "#AAA0AE",
fontSize: 25,
fontWeight: 450,
letterSpacing: -0.5,
}}
>
Send files directly. Stay in control.
</div>
<TransferIllustration />
</div>,
imageSize,
);
}

169
docs/app/page.tsx Normal file
View File

@@ -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 (
<main id="main-content">
<section className="hero-section">
<div className="hero-ambient hero-ambient-one" aria-hidden="true" />
<div className="hero-ambient hero-ambient-two" aria-hidden="true" />
<div className="page-shell hero-layout">
<div className="hero-copy">
<Reveal>
<div className="eyebrow-badge">
<span className="eyebrow-dot" />
OPEN SOURCE · EARLY DEVELOPMENT
</div>
<h1>
Your files.
<br />
<span>A straight line</span>
<br />
between devices.
</h1>
<p className="hero-lead">
Send files and folders across your devicesdirect when possible, private by design,
and always under your control.
</p>
<div className="hero-actions">
<a className="button button-primary" href="#how-it-works">
See how it works
<Icon name="arrow" />
</a>
</div>
<div className="hero-platforms" aria-label="Supported platforms">
<span>Available across</span>
<ul>
{supportedPlatforms.map((platform) => (
<li key={platform} aria-label={platform} title={platform}>
<PlatformIcon className="platform-glyph" platform={platform} />
<span className="sr-only">{platform}</span>
</li>
))}
</ul>
</div>
</Reveal>
</div>
<Reveal className="hero-art-reveal" delay={120}>
<figure className="hero-app-preview">
<Image
className="hero-app-preview-image"
src="/App.png"
width={2338}
height={1873}
sizes="(max-width: 920px) calc(100vw - 32px), 56vw"
alt="VniDrop running on desktop and iPhone, showing transfer review, receiver permissions, and invitation options."
priority
unoptimized
/>
</figure>
</Reveal>
</div>
</section>
<section id="how-it-works" className="simple-section simple-flow-section">
<div className="page-shell">
<Reveal className="simple-heading">
<span className="kicker">HOW IT WORKS</span>
<h2>Three steps. No account.</h2>
<p>
Choose the files, introduce the devices, and approve the handoff. VniDrop handles the
secure route.
</p>
</Reveal>
<div className="simple-steps">
{steps.map((step) => (
<article key={step.number} className="simple-step">
<div className="simple-step-top">
<span>{step.number}</span>
<Icon name={step.icon} />
</div>
<h3>{step.title}</h3>
<p>{step.text}</p>
</article>
))}
</div>
</div>
</section>
<section id="privacy" className="trust-summary-section">
<div className="page-shell trust-summary">
<Reveal className="trust-summary-copy">
<span className="kicker kicker-light">PRIVACY BY DESIGN</span>
<h2>What VniDrop doesand doesnt do.</h2>
<p>
The transfer happens between devices. These are the details that matter when you
decide what to share and who can receive it.
</p>
<Link className="simple-text-link" href="/privacy/">
Read the privacy policy
<Icon name="arrow" />
</Link>
</Reveal>
<div className="trust-summary-list">
{trustItems.map((item) => (
<article key={item.title} className="trust-summary-item">
<span><Icon name={item.icon} /></span>
<div>
<h3>{item.title}</h3>
<p>{item.text}</p>
</div>
</article>
))}
</div>
</div>
</section>
</main>
);
}

400
docs/app/privacy/page.tsx Normal file
View File

@@ -0,0 +1,400 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Privacy policy",
description:
"How VniDrop handles transfers, local app data, optional diagnostics, bug reports, and website visits.",
};
const sections = [
["scope", "Scope"],
["transfers", "Transfers"],
["local-data", "Local data"],
["diagnostics", "Diagnostics"],
["website", "Website"],
["permissions", "Permissions"],
["providers", "Service providers"],
["retention", "Retention"],
["choices", "Your choices"],
["security", "Security"],
["changes", "Changes"],
["contact", "Contact"],
];
export default function PrivacyPage() {
return (
<main id="main-content" className="privacy-page">
<section className="privacy-hero">
<div className="page-shell privacy-hero-inner">
<h1>Privacy Policy</h1>
<p>
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.
</p>
<p className="privacy-meta">Effective July 16, 2026 · Version 1.1</p>
</div>
</section>
<section className="privacy-document-section">
<div className="page-shell privacy-document-layout">
<aside className="privacy-toc">
<p>On this page</p>
<nav aria-label="Privacy policy sections">
<ol>
{sections.map(([id, label]) => (
<li key={id}>
<a href={`#${id}`}>{label}</a>
</li>
))}
</ol>
</nav>
</aside>
<article className="privacy-document">
<div className="privacy-callout">
<strong>The short version</strong>
<p>
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.
</p>
</div>
<section id="scope" className="policy-section">
<h2>Scope and who VniDrop means</h2>
<p>
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, VniDrops 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 publishers
behalf. The publisher can be reached at support@sudosy.fr.
</p>
<p>
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.
</p>
</section>
<section id="transfers" className="policy-section">
<h2>What happens during a transfer</h2>
<h3>File contents</h3>
<p>
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.
</p>
<h3>Invitations and transfer metadata</h3>
<p>
A QR code, NFC tag, or <code>.vnd</code> 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.
</p>
<h3>What peers and relays can see</h3>
<p>
A receive request can disclose the receivers 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.
</p>
<div className="policy-note">
<p>
Approval is required by default. If the sender selects Anyone with this transfer,
anyone holding the invitation may receive the files until sharing stops.
</p>
</div>
</section>
<section id="local-data" className="policy-section">
<h2>Information kept on your device</h2>
<p>VniDrop stores the information needed to operate the app locally, including:</p>
<ul>
<li>device identity and networking keys used to establish secure connections;</li>
<li>active shares, transfer history, receiver requests, progress, and status;</li>
<li>app preferences, including access and diagnostics choices;</li>
<li>download destinations and locally managed transfer data; and</li>
<li>an anonymous installation identifier used only for diagnostics correlation.</li>
</ul>
<p>
This information remains until you remove the relevant history, stop or delete a
share, clear the apps 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.
</p>
</section>
<section id="diagnostics" className="policy-section">
<h2>Optional diagnostics and bug reports</h2>
<h3>Automatic product diagnostics</h3>
<p>
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.
</p>
<h3>User-submitted bug reports</h3>
<p>
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.
</p>
<h3>Data deliberately excluded</h3>
<p>
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.
</p>
</section>
<section id="website" className="policy-section">
<h2>The VniDrop website</h2>
<p>
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.
</p>
<p>
Vercel hosts the static site, while Cloudflare proxies requests and provides DNS and
security services for the domain. They may process routine request informationsuch
as IP address, time, requested page, referrer, and browser user agentto deliver the
site, maintain reliability, and prevent abuse.
</p>
</section>
<section id="permissions" className="policy-section">
<h2>Device permissions</h2>
<dl className="permission-list">
<div>
<dt>Files &amp; folders</dt>
<dd>Choose what to send and where received files are saved.</dd>
</div>
<div>
<dt>Camera / scanner</dt>
<dd>Scan a QR invitation when you choose that receive method.</dd>
</div>
<div>
<dt>NFC</dt>
<dd>Read or write an invitation through a compatible NFC tag.</dd>
</div>
<div>
<dt>Network &amp; notifications</dt>
<dd>Connect peers and alert you to background receiver requests.</dd>
</div>
</dl>
<p>
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.
</p>
</section>
<section id="providers" className="policy-section">
<h2>Infrastructure and external services</h2>
<dl className="provider-list">
<div>
<dt>Iroh / public relay operators</dt>
<dd>
Device discovery, connection establishment, and encrypted relay fallback.
Relays process connection metadata but cannot decrypt transfer contents.
</dd>
</div>
<div>
<dt>Vercel</dt>
<dd>
Hosts and serves the static VniDrop website and processes routine request and
delivery metadata.
</dd>
</div>
<div>
<dt>Cloudflare</dt>
<dd>
Proxies website requests and provides DNS, security, and abuse controls. When
the optional diagnostics service is configured, it uses Cloudflare Workers, D1,
and R2.
</dd>
</div>
<div>
<dt>Google Play services</dt>
<dd>
May provide the QR code scanner on supported Android devices when you choose to
scan an invitation.
</dd>
</div>
<div>
<dt>GitHub</dt>
<dd>
Hosts the source repository, issue tracker, and external pages linked from this
site. GitHubs own privacy terms apply after you follow those links.
</dd>
</div>
</dl>
<p className="provider-links">
Provider policies:{" "}
<a
href="https://services.iroh.computer/legal/privacy"
target="_blank"
rel="noreferrer"
>
Iroh
</a>
,{" "}
<a
href="https://www.cloudflare.com/policies/privacy/"
target="_blank"
rel="noreferrer"
>
Cloudflare
</a>
,{" "}
<a
href="https://vercel.com/legal/privacy-notice"
target="_blank"
rel="noreferrer"
>
Vercel
</a>
,{" "}
<a href="https://policies.google.com/privacy" target="_blank" rel="noreferrer">
Google
</a>
, and{" "}
<a
href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement"
target="_blank"
rel="noreferrer"
>
GitHub
</a>
.
</p>
</section>
<section id="retention" className="policy-section">
<h2>Retention and deletion</h2>
<div className="retention-table-wrap">
<table className="retention-table">
<caption className="sr-only">Data retention periods</caption>
<thead>
<tr>
<th scope="col">Data</th>
<th scope="col">Typical retention</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Transfer history and settings</th>
<td>Until you delete them, clear app data, or uninstall</td>
</tr>
<tr>
<th scope="row">Pending local crash reports</th>
<td>Up to 30 days and 20 reports; deleted when diagnostics is disabled</td>
</tr>
<tr>
<th scope="row">Server diagnostics and bug reports</th>
<td>The current project configuration is 90 days, with scheduled deletion</td>
</tr>
<tr>
<th scope="row">Downloaded files</th>
<td>Until you delete them through your operating system</td>
</tr>
</tbody>
</table>
</div>
<p>
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.
</p>
</section>
<section id="choices" className="policy-section">
<h2>Your choices and rights</h2>
<ul>
<li>Enable or disable Share diagnostics in VniDrop settings.</li>
<li>
Submit a bug report only when you choose, omit contact information, and exclude
logs.
</li>
<li>Approve or refuse each receiver, cancel a transfer, or stop sharing.</li>
<li>
Delete individual transfer history or clear completed, failed, and cancelled
receive history.
</li>
<li>
Delete downloaded files using your operating system, or clear all app data by
uninstalling or resetting the app.
</li>
</ul>
<p>
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.
</p>
</section>
<section id="security" className="policy-section">
<h2>Security</h2>
<p>
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.
</p>
<p>
Please report a suspected vulnerability through the private process in the{" "}
<a
href="https://github.com/vnidrop/vnidrop/blob/master/SECURITY.md"
target="_blank"
rel="noreferrer"
>
VniDrop security policy
</a>
, not in a public issue.
</p>
</section>
<section id="changes" className="policy-section">
<h2>Changes to this policy</h2>
<p>
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.
</p>
</section>
<section id="contact" className="policy-section policy-contact">
<h2>Contact</h2>
<p>
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.
</p>
<a className="privacy-contact-link" href="mailto:support@sudosy.fr">
Email support@sudosy.fr
</a>
</section>
</article>
</div>
</section>
</main>
);
}

View File

@@ -0,0 +1,288 @@
.privacy-page {
background: var(--surface);
}
.privacy-hero {
padding: 158px 0 72px;
border-bottom: 1px solid var(--line);
text-align: center;
}
.privacy-hero-inner {
display: flex;
flex-direction: column;
align-items: center;
}
.privacy-hero h1 {
margin-bottom: 22px;
font-size: clamp(3.25rem, 5.2vw, 4.5rem);
font-weight: 700;
letter-spacing: -0.055em;
line-height: 1;
}
.privacy-hero-inner > p:not(.privacy-meta) {
max-width: 640px;
margin-bottom: 24px;
color: var(--ink-soft);
font-size: 16px;
line-height: 1.7;
}
.privacy-meta {
margin: 0;
color: var(--ink-muted);
font-size: 13px;
}
.privacy-document-section {
padding: 80px 0 144px;
}
.privacy-document-layout {
display: grid;
grid-template-areas: "article toc";
grid-template-columns: minmax(0, 720px) 220px;
justify-content: center;
gap: 92px;
}
.privacy-document {
grid-area: article;
min-width: 0;
}
.privacy-toc {
position: sticky;
top: 108px;
grid-area: toc;
align-self: start;
padding-left: 24px;
border-left: 1px solid var(--line);
}
.privacy-toc > p {
margin-bottom: 14px;
color: var(--ink);
font-size: 13px;
font-weight: 700;
}
.privacy-toc ol {
display: grid;
gap: 1px;
margin: 0;
padding: 0;
list-style: none;
}
.privacy-toc a {
display: block;
padding: 5px 0;
color: var(--ink-muted);
font-size: 12px;
line-height: 1.45;
transition: color 160ms ease;
}
.privacy-toc a:hover {
color: var(--ink);
}
.privacy-callout {
margin-bottom: 64px;
padding: 0 0 40px 20px;
border-bottom: 1px solid var(--line);
border-left: 2px solid var(--ink);
}
.privacy-callout strong {
display: block;
margin-bottom: 8px;
font-family: var(--font-display);
font-size: 17px;
}
.privacy-callout p {
margin: 0;
color: var(--ink-soft);
font-size: 15px;
line-height: 1.75;
}
.policy-section {
scroll-margin-top: 104px;
padding-bottom: 56px;
}
.policy-section + .policy-section {
padding-top: 56px;
border-top: 1px solid var(--line);
}
.policy-section h2 {
margin-bottom: 24px;
font-size: clamp(1.75rem, 2.7vw, 2rem);
font-weight: 680;
letter-spacing: -0.035em;
line-height: 1.22;
}
.policy-section h3 {
margin: 32px 0 10px;
font-size: 19px;
font-weight: 680;
letter-spacing: -0.018em;
line-height: 1.35;
}
.policy-section p,
.policy-section li,
.permission-list dd,
.provider-list dd {
color: var(--ink-soft);
font-size: 15px;
line-height: 1.75;
}
.policy-section p {
margin-bottom: 18px;
}
.policy-section ul {
display: grid;
gap: 7px;
margin: 18px 0 24px;
padding-left: 22px;
}
.policy-section li::marker {
color: var(--ink-muted);
}
.policy-section code {
padding: 2px 5px;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--paper-warm);
color: var(--ink);
font-family: var(--font-mono);
font-size: 0.88em;
}
.policy-section a,
.privacy-contact-link {
color: var(--brand-700);
font-weight: 620;
text-decoration: underline;
text-decoration-color: rgba(111, 39, 233, 0.34);
text-underline-offset: 3px;
}
.policy-section a:hover,
.privacy-contact-link:hover {
text-decoration-color: currentColor;
}
.policy-note {
margin-top: 28px;
padding: 18px 20px;
border-left: 2px solid var(--line-strong);
background: var(--paper-warm);
}
.policy-note p {
margin: 0;
font-size: 14px;
line-height: 1.7;
}
.permission-list,
.provider-list {
margin: 28px 0;
border-top: 1px solid var(--line);
}
.permission-list > div,
.provider-list > div {
display: grid;
grid-template-columns: minmax(150px, 0.42fr) 1fr;
gap: 24px;
padding: 17px 0;
border-bottom: 1px solid var(--line);
}
.permission-list dt,
.provider-list dt {
color: var(--ink);
font-size: 14px;
font-weight: 680;
line-height: 1.55;
}
.permission-list dd,
.provider-list dd {
margin: 0;
font-size: 14px;
line-height: 1.65;
}
.provider-links {
font-size: 14px !important;
}
.retention-table-wrap {
overflow-x: auto;
margin: 28px 0;
border-top: 1px solid var(--line);
-webkit-overflow-scrolling: touch;
}
.retention-table {
width: 100%;
min-width: 600px;
border-collapse: collapse;
text-align: left;
}
.retention-table th,
.retention-table td {
padding: 15px 16px 15px 0;
border-bottom: 1px solid var(--line);
color: var(--ink-soft);
font-size: 13px;
font-weight: 400;
line-height: 1.6;
vertical-align: top;
}
.retention-table th:first-child {
width: 38%;
}
.retention-table thead th {
color: var(--ink-muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.retention-table tbody th {
color: var(--ink);
font-weight: 620;
}
.policy-contact {
padding-bottom: 0;
}
.policy-contact p {
margin-bottom: 18px;
}
.privacy-contact-link::after {
margin-left: 5px;
content: "↗";
}

View File

@@ -0,0 +1,89 @@
@media (max-width: 940px) {
.privacy-document-layout {
grid-template-areas:
"toc"
"article";
grid-template-columns: minmax(0, 720px);
gap: 56px;
}
.privacy-toc {
position: static;
padding: 0 0 28px;
border-bottom: 1px solid var(--line);
border-left: 0;
}
.privacy-toc ol {
grid-template-columns: repeat(3, minmax(0, 1fr));
column-gap: 24px;
}
}
@media (max-width: 640px) {
.privacy-hero {
padding: 122px 0 54px;
}
.privacy-hero h1 {
margin-bottom: 18px;
font-size: clamp(2.55rem, 12vw, 3.4rem);
}
.privacy-hero-inner > p:not(.privacy-meta) {
font-size: 15px;
line-height: 1.65;
}
.privacy-meta {
font-size: 12px;
}
.privacy-document-section {
padding: 56px 0 96px;
}
.privacy-document-layout {
gap: 0;
}
.privacy-toc {
display: none;
}
.privacy-callout {
margin-bottom: 48px;
padding: 0 0 32px 16px;
}
.privacy-callout p,
.policy-section p,
.policy-section li,
.permission-list dd,
.provider-list dd {
font-size: 14px;
}
.policy-section {
padding-bottom: 44px;
}
.policy-section + .policy-section {
padding-top: 44px;
}
.policy-section h2 {
font-size: 1.65rem;
}
.policy-section h3 {
margin-top: 28px;
font-size: 17px;
}
.permission-list > div,
.provider-list > div {
grid-template-columns: 1fr;
gap: 5px;
}
}

191
docs/app/responsive.css Normal file
View File

@@ -0,0 +1,191 @@
@keyframes ambient-drift {
from { transform: translate3d(-10px, -8px, 0) rotate(0deg); }
to { transform: translate3d(24px, 18px, 0) rotate(8deg); }
}
@media (max-width: 1120px) {
.hero-layout {
grid-template-columns: minmax(0, 0.88fr) minmax(470px, 1.12fr);
}
.hero-copy h1 {
font-size: clamp(3.2rem, 5.4vw, 4.5rem);
}
.trust-summary {
gap: 65px;
}
}
@media (max-width: 920px) {
.hero-section {
padding-top: 128px;
}
.hero-layout {
grid-template-columns: 1fr;
gap: 35px;
}
.hero-copy {
max-width: 720px;
margin: 0 auto;
text-align: center;
}
.hero-lead {
margin-right: auto;
margin-left: auto;
}
.hero-actions,
.hero-platforms {
justify-content: center;
}
.hero-app-preview {
width: min(100%, 780px);
margin: 12px auto 0;
}
.simple-step {
padding-right: 22px;
padding-left: 22px;
}
.trust-summary {
grid-template-columns: 1fr;
gap: 50px;
}
.trust-summary-copy {
max-width: 680px;
}
}
@media (max-width: 680px) {
.page-shell {
width: min(calc(100% - 32px), var(--shell));
}
.hero-section {
min-height: auto;
padding: 115px 0 72px;
}
.hero-copy h1 {
font-size: clamp(2.75rem, 14vw, 4.25rem);
letter-spacing: -0.065em;
}
.hero-lead {
font-size: 16px;
}
.hero-actions {
display: grid;
}
.hero-actions .button {
width: 100%;
}
.hero-platforms {
display: grid;
justify-items: center;
}
.hero-platforms ul {
justify-content: center;
}
.hero-app-preview {
width: calc(100% + 24px);
margin-left: -12px;
}
.simple-section,
.trust-summary-section {
padding: 88px 0;
}
.simple-heading {
margin-bottom: 40px;
}
.simple-heading h2,
.trust-summary-copy h2 {
font-size: clamp(2.35rem, 12vw, 3.5rem);
}
.simple-heading p,
.trust-summary-copy p {
font-size: 15px;
}
.simple-steps {
grid-template-columns: 1fr;
}
.simple-step {
min-height: 0;
padding: 28px 0 32px;
}
.simple-step + .simple-step {
border-top: 1px solid var(--line);
border-left: 0;
}
.simple-step-top {
margin-bottom: 28px;
}
.trust-summary-item {
grid-template-columns: 40px 1fr;
gap: 15px;
}
.trust-summary-item > span {
width: 38px;
height: 38px;
}
.footer-inner,
.footer-bottom {
align-items: flex-start;
flex-direction: column;
}
.footer-inner {
gap: 24px;
}
.footer-links {
flex-wrap: wrap;
}
.footer-bottom {
gap: 5px;
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.motion-ready .reveal {
opacity: 1;
transform: none;
}
}

33
docs/components/brand.tsx Normal file
View File

@@ -0,0 +1,33 @@
import Link from "next/link";
import Image from "next/image";
type BrandAssetProps = {
className?: string;
title?: string;
};
export function BrandMark({ className, title }: BrandAssetProps) {
return (
<Image
className={className}
src="/brand-mark.svg"
width={1024}
height={1024}
alt={title ?? ""}
aria-hidden={title ? undefined : true}
unoptimized
/>
);
}
export function Brand() {
return (
<Link className="brand" href="/" aria-label="VniDrop home">
<span className="brand-primary">
<BrandMark className="brand-mark-image" />
<span className="brand-name">VniDrop</span>
</span>
<span className="brand-tagline">Send files directly. Stay in control.</span>
</Link>
);
}

151
docs/components/header.css Normal file
View File

@@ -0,0 +1,151 @@
.site-header {
position: fixed;
z-index: 220;
top: 0;
right: 0;
left: 0;
height: 72px;
border-bottom: 1px solid rgba(224, 217, 228, 0.76);
background: rgba(251, 250, 252, 0.88);
box-shadow: 0 8px 30px rgba(42, 26, 49, 0.035);
backdrop-filter: blur(20px) saturate(1.3);
-webkit-backdrop-filter: blur(20px) saturate(1.3);
}
.header-inner {
display: flex;
height: 72px;
align-items: center;
justify-content: space-between;
}
.brand {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
width: max-content;
}
.brand-primary {
display: inline-flex;
align-items: center;
gap: 3px;
height: 47px;
}
.brand-mark-image {
width: 48px;
height: 48px;
object-fit: contain;
filter: drop-shadow(0 7px 14px rgba(126, 42, 189, 0.14));
}
.brand-name {
color: var(--ink);
font-family: var(--font-display);
font-size: 19px;
font-weight: 780;
line-height: 1;
letter-spacing: -0.045em;
}
.brand-tagline {
margin-top: -4px;
margin-left: 4px;
color: #756c79;
font-size: 9px;
font-weight: 580;
line-height: 1;
letter-spacing: -0.01em;
white-space: nowrap;
}
.header-github-button {
display: inline-flex;
min-height: 40px;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 14px;
border: 1px solid #28232b;
border-radius: 10px;
background: var(--ink);
box-shadow: 0 8px 22px rgba(23, 20, 25, 0.13), inset 0 1px rgba(255, 255, 255, 0.1);
color: white;
font-size: 12px;
font-weight: 680;
line-height: 1;
animation: none;
transform: none;
}
.header-github-button:hover {
border-color: #6f6475;
background: #302a34;
box-shadow: 0 11px 28px rgba(23, 20, 25, 0.2), inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.header-github-button:focus-visible {
outline: 3px solid rgba(168, 85, 247, 0.28);
outline-offset: 3px;
}
.header-github-button svg {
width: 17px;
height: 16px;
flex: 0 0 auto;
}
@media (max-width: 680px) {
.site-header,
.header-inner {
height: 64px;
}
.brand-mark-image {
width: 44px;
height: 44px;
}
.brand-primary {
height: 43px;
}
.brand-name {
font-size: 17px;
}
.brand-tagline {
margin-top: -3px;
margin-left: 3px;
font-size: 8px;
}
.header-github-button {
min-height: 38px;
gap: 7px;
padding-inline: 12px;
font-size: 11px;
}
}
@media (max-width: 360px) {
.brand-mark-image {
width: 41px;
height: 41px;
}
.brand-name {
font-size: 16px;
}
.brand-tagline {
font-size: 7.5px;
}
.header-github-button {
min-height: 36px;
padding-inline: 10px;
font-size: 10px;
}
}

85
docs/components/icons.tsx Normal file
View File

@@ -0,0 +1,85 @@
import type { SVGProps } from "react";
export type IconName =
| "arrow"
| "devices"
| "folder"
| "github"
| "lock"
| "qr"
| "shield"
| "verified";
type IconProps = SVGProps<SVGSVGElement> & {
name: IconName;
};
export function Icon({ name, ...props }: IconProps) {
const paths: Record<IconName, React.ReactNode> = {
arrow: (
<>
<path d="M5 12h14" />
<path d="m14 7 5 5-5 5" />
</>
),
devices: (
<>
<rect x="3" y="5" width="13" height="10" rx="2" />
<path d="M7 19h5M9.5 15v4" />
<rect x="17" y="8" width="4" height="10" rx="1" />
</>
),
folder: (
<path d="M3 7.5A2.5 2.5 0 0 1 5.5 5H10l2 2h6.5A2.5 2.5 0 0 1 21 9.5v7a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 16.5z" />
),
github: (
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.5 2.22168C5.23312 2.22168 2.58496 4.87398 2.58496 8.14677C2.58496 10.7642 4.27962 12.9853 6.63026 13.7684C6.92601 13.8228 7.03366 13.6401 7.03366 13.4827C7.03366 13.3425 7.02893 12.9693 7.02597 12.4754C5.38041 12.8333 5.0332 11.681 5.0332 11.681C4.76465 10.996 4.37663 10.8139 4.37663 10.8139C3.83954 10.4471 4.41744 10.4542 4.41744 10.4542C5.01072 10.4956 5.32303 11.0647 5.32303 11.0647C5.85065 11.9697 6.70774 11.7082 7.04431 11.5568C7.09873 11.1741 7.25134 10.9132 7.42051 10.7654C6.10737 10.6157 4.72621 10.107 4.72621 7.83683C4.72621 7.19031 4.95689 6.66092 5.33486 6.24686C5.27394 6.09721 5.07105 5.49447 5.39283 4.67938C5.39283 4.67938 5.88969 4.51967 7.01947 5.28626C7.502 5.15466 7.99985 5.08763 8.5 5.08692C9.00278 5.08929 9.50851 5.15495 9.98113 5.28626C11.1103 4.51967 11.606 4.67879 11.606 4.67879C11.9289 5.49447 11.7255 6.09721 11.6651 6.24686C12.0437 6.66092 12.2732 7.19031 12.2732 7.83683C12.2732 10.1129 10.8897 10.6139 9.5724 10.7606C9.78475 10.9434 9.97344 11.3048 9.97344 11.8579C9.97344 12.6493 9.96634 13.2887 9.96634 13.4827C9.96634 13.6413 10.0728 13.8258 10.3733 13.7678C11.5512 13.3728 12.5751 12.6175 13.3003 11.6089C14.0256 10.6002 14.4155 9.38912 14.415 8.14677C14.415 4.87398 11.7663 2.22168 8.5 2.22168Z"
fill="currentColor"
/>
),
lock: (
<>
<rect x="5" y="10" width="14" height="11" rx="3" />
<path d="M8 10V7a4 4 0 0 1 8 0v3M12 14v3" />
</>
),
qr: (
<>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<path d="M14 14h3v3h-3zM18 18h3v3h-3zM18 13h3M13 19h3v2" />
</>
),
shield: (
<>
<path d="M12 3 4.5 6v5.5c0 4.8 3.2 8.1 7.5 9.5 4.3-1.4 7.5-4.7 7.5-9.5V6z" />
<path d="m8.5 12 2.2 2.2 4.8-5" />
</>
),
verified: (
<>
<path d="m12 3 2 2.1 2.9-.1.1 2.9 2 2.1-2 2.1-.1 2.9-2.9-.1-2 2.1-2-2.1-2.9.1-.1-2.9-2-2.1 2-2.1.1-2.9 2.9.1z" />
<path d="m9 10 2 2 4-4" />
</>
),
};
return (
<svg
viewBox={name === "github" ? "0 0 17 16" : "0 0 24 24"}
fill="none"
stroke={name === "github" ? "none" : "currentColor"}
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
{paths[name]}
</svg>
);
}

View File

@@ -0,0 +1,90 @@
import type { SVGProps } from "react";
export const supportedPlatforms = ["Android", "iOS", "macOS", "Windows", "Linux"] as const;
export type PlatformName = (typeof supportedPlatforms)[number];
type PlatformIconProps = Omit<SVGProps<SVGSVGElement>, "children"> & {
platform: PlatformName;
};
function AndroidIcon() {
return (
<>
<path
d="M7.25 8.15h9.5a1.5 1.5 0 0 1 1.5 1.5v6.1a1.5 1.5 0 0 1-1.5 1.5h-9.5a1.5 1.5 0 0 1-1.5-1.5v-6.1a1.5 1.5 0 0 1 1.5-1.5Z"
fill="currentColor"
/>
<path d="M8.15 8.1a3.95 3.95 0 0 1 7.7 0" fill="currentColor" />
<path d="m8.35 4.45-1.2-1.7M15.65 4.45l1.2-1.7" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
<path d="M4.2 10.05v5.15M19.8 10.05v5.15M8.5 17v3.05M15.5 17v3.05" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" />
<circle cx="9.5" cy="6.65" r="0.62" fill="white" />
<circle cx="14.5" cy="6.65" r="0.62" fill="white" />
</>
);
}
function AppleIcon() {
return (
<path
d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.32.07 2.24.76 3.01.82 1.15-.23 2.25-.89 3.44-.8 1.43.12 2.51.68 3.22 1.7-2.96 1.78-2.26 5.68.46 6.77-.54 1.42-1.24 2.83-2.13 4.48ZM12.03 7.25c-.15-2.13 1.59-3.89 3.58-4.06.27 2.46-2.23 4.3-3.58 4.06Z"
fill="currentColor"
/>
);
}
function MacIcon() {
return (
<>
<rect x="2.5" y="2.5" width="19" height="19" rx="5.5" fill="currentColor" />
<path d="M12 2.5v19" stroke="white" strokeOpacity="0.72" />
<path d="M8 9.15h.01M16 9.15h.01" stroke="white" strokeWidth="1.9" strokeLinecap="round" />
<path d="M7.1 15.2c2.7 1.45 7.1 1.45 9.8 0" stroke="white" strokeWidth="1.35" strokeLinecap="round" />
<path d="m13.55 6.25-2.45 8.3" stroke="white" strokeWidth="1.1" strokeLinecap="round" strokeOpacity="0.82" />
</>
);
}
function WindowsIcon() {
return (
<path
d="M3 5.15 11 4v7H3V5.15Zm9-1.3L21 2.5V11h-9V3.85ZM3 12h8v7l-8-1.15V12Zm9 0h9v8.5l-9-1.35V12Z"
fill="currentColor"
/>
);
}
function LinuxIcon() {
return (
<>
<ellipse cx="12" cy="12.6" rx="5.25" ry="7.55" fill="#242027" />
<ellipse cx="12" cy="14.55" rx="3.45" ry="4.7" fill="#f8f7f9" />
<ellipse cx="9.95" cy="8.45" rx="1.35" ry="1.8" fill="white" />
<ellipse cx="14.05" cy="8.45" rx="1.35" ry="1.8" fill="white" />
<circle cx="10.35" cy="8.65" r="0.55" fill="#242027" />
<circle cx="13.65" cy="8.65" r="0.55" fill="#242027" />
<path d="m9.8 10.15 2.2-1 2.2 1-2.2 2.1-2.2-2.1Z" fill="#f5b82e" />
<path d="M6.15 18.45c1.2-1 2.45-1.3 3.75-.75-.7 1.6-2.35 2.45-4.9 2.2.2-.6.58-1.08 1.15-1.45ZM17.85 18.45c-1.2-1-2.45-1.3-3.75-.75.7 1.6 2.35 2.45 4.9 2.2-.2-.6-.58-1.08-1.15-1.45Z" fill="#f5b82e" />
</>
);
}
export function PlatformIcon({ platform, className, ...props }: PlatformIconProps) {
return (
<svg
className={className}
data-platform={platform.toLowerCase()}
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
focusable="false"
{...props}
>
{platform === "Android" && <AndroidIcon />}
{platform === "iOS" && <AppleIcon />}
{platform === "macOS" && <MacIcon />}
{platform === "Windows" && <WindowsIcon />}
{platform === "Linux" && <LinuxIcon />}
</svg>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import type { CSSProperties, ReactNode } from "react";
import { useEffect, useRef } from "react";
type RevealProps = {
children: ReactNode;
className?: string;
delay?: number;
};
export function Reveal({ children, className = "", delay = 0 }: RevealProps) {
const targetRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const root = document.documentElement;
root.classList.add("motion-ready");
const target = targetRef.current;
if (!target) return;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceMotion || !("IntersectionObserver" in window)) {
target.classList.add("is-visible");
return;
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.classList.add("is-visible");
observer.unobserve(entry.target);
});
},
{ rootMargin: "0px 0px -10%", threshold: 0.08 },
);
observer.observe(target);
return () => observer.disconnect();
}, []);
return (
<div
ref={targetRef}
className={`reveal ${className}`.trim()}
data-reveal
style={{ "--reveal-delay": `${delay}ms` } as CSSProperties}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import Link from "next/link";
import { BrandMark } from "@/components/brand";
import { Icon } from "@/components/icons";
export function SiteFooter() {
return (
<footer className="site-footer">
<div className="footer-inner page-shell">
<div className="footer-identity">
<BrandMark />
<span>VniDrop</span>
</div>
<nav className="footer-links" aria-label="Footer navigation">
<a href="https://github.com/vnidrop/vnidrop" target="_blank" rel="noreferrer">
<Icon name="github" /> GitHub
</a>
<Link href="/privacy/">Privacy</Link>
<a
href="https://github.com/vnidrop/vnidrop/blob/master/LICENSE"
target="_blank"
rel="noreferrer"
>
Apache 2.0
</a>
</nav>
</div>
<div className="footer-bottom page-shell">
<p>© 2026 VniDrop contributors.</p>
<p>Open source · Early development</p>
</div>
</footer>
);
}

View File

@@ -0,0 +1,22 @@
import Link from "next/link";
import { Brand } from "@/components/brand";
import { Icon } from "@/components/icons";
export function SiteHeader() {
return (
<header className="site-header">
<div className="header-inner page-shell">
<Brand />
<Link
className="header-github-button"
href="https://github.com/vnidrop/vnidrop"
target="_blank"
rel="noreferrer"
>
<Icon name="github" />
View on GitHub
</Link>
</div>
</header>
);
}

9
docs/eslint.config.mjs Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTypeScript from "eslint-config-next/typescript";
export default defineConfig([
...nextVitals,
...nextTypeScript,
globalIgnores([".next/**", "out/**", "next-env.d.ts"]),
]);

6
docs/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

8
docs/next.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
trailingSlash: true,
};
export default nextConfig;

6082
docs/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
docs/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "vnidrop-site",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=22.12"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"next": "16.2.10",
"react": "19.2.7",
"react-dom": "19.2.7"
},
"devDependencies": {
"@types/node": "26.1.1",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"eslint": "9.39.5",
"eslint-config-next": "16.2.10",
"typescript": "5.9.3"
},
"overrides": {
"postcss": "8.5.19"
}
}

BIN
docs/public/App.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -0,0 +1,26 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.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.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="url(#paint0_linear_11_12)"/>
<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" fill="url(#paint1_linear_11_12)"/>
<mask id="mask0_11_12" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="176" y="148" width="671" height="732">
<path d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.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.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="white"/>
</mask>
<g mask="url(#mask0_11_12)">
<path d="M688 148H782C832 148 842 171.8 847.48 210V303.68L688 148Z" fill="url(#paint2_linear_11_12)"/>
</g>
<defs>
<linearGradient id="paint0_linear_11_12" x1="176" y1="148" x2="904.706" y2="816.252" gradientUnits="userSpaceOnUse">
<stop stop-color="#A855F7"/>
<stop offset="0.48" stop-color="#9D4DF4"/>
<stop offset="1" stop-color="#7C2AEF"/>
</linearGradient>
<linearGradient id="paint1_linear_11_12" x1="404.439" y1="431.72" x2="707.314" y2="649.286" gradientUnits="userSpaceOnUse">
<stop stop-color="#A855F7"/>
<stop offset="0.48" stop-color="#9D4DF4"/>
<stop offset="1" stop-color="#7C2AEF"/>
</linearGradient>
<linearGradient id="paint2_linear_11_12" x1="683.48" y1="144.6" x2="793.636" y2="306.833" gradientUnits="userSpaceOnUse">
<stop stop-color="#F2DDFF"/>
<stop offset="1" stop-color="#C084FC"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1,16 @@
{
"name": "VniDrop",
"short_name": "VniDrop",
"description": "Direct, private file transfer across your devices.",
"start_url": "/",
"display": "standalone",
"background_color": "#fbfafc",
"theme_color": "#a855f7",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml"
}
]
}

41
docs/tsconfig.json Normal file
View File

@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
".next/types/**/*.ts",
"**/*.ts",
"**/*.tsx",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@@ -6,6 +6,11 @@ kotlin.mpp.enableCInteropCommonization=true
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
org.gradle.configuration-cache=true
org.gradle.caching=true
# Product version used by desktop packaging. Release workflows override this
# from the vMAJOR.MINOR.PATCH tag.
vnidrop.version=1.0.0
#Android
android.builtInKotlin=false
android.newDsl=false
@@ -16,8 +21,9 @@ android.useAndroidX=true
# VniDrop: compile-time diagnostics/telemetry product surface.
# false → no Share-diagnostics toggle, no telemetry or crash auto-upload stack.
# Bug report UI remains available (user-initiated).
# Override per build: ./gradlew … -Pvnidrop.diagnostics.included=false
vnidrop.diagnostics.included=true
# Enable per build only when endpoint and ingest key are configured:
# ./gradlew … -Pvnidrop.diagnostics.included=true
vnidrop.diagnostics.included=false
# Cloudflare Worker base URL (no trailing slash). Both endpoint/key empty → NoOp transport.
# Example: https://vnidrop-diagnostics.<subdomain>.workers.dev
vnidrop.diagnostics.endpoint=

View File

@@ -1,7 +1,7 @@
TEAM_ID=
PRODUCT_NAME=vnidrop
PRODUCT_NAME=VniDrop
PRODUCT_BUNDLE_IDENTIFIER=com.vnidrop.app.vnidrop$(TEAM_ID)
CURRENT_PROJECT_VERSION=1
MARKETING_VERSION=1.0
MARKETING_VERSION=1.0

View File

@@ -7,7 +7,7 @@
objects = {
/* Begin PBXFileReference section */
FA325F1B4E7D8FFDF19A5C4A /* vnidrop.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = vnidrop.app; sourceTree = BUILT_PRODUCTS_DIR; };
FA325F1B4E7D8FFDF19A5C4A /* VniDrop.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VniDrop.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -47,14 +47,6 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
C13A067056BA9F38FD87A539 /* Products */ = {
isa = PBXGroup;
children = (
FA325F1B4E7D8FFDF19A5C4A /* vnidrop.app */,
);
name = Products;
sourceTree = "<group>";
};
CBA2EB46BC3B70D303C46327 = {
isa = PBXGroup;
children = (
@@ -64,6 +56,14 @@
);
sourceTree = "<group>";
};
C13A067056BA9F38FD87A539 /* Products */ = {
isa = PBXGroup;
children = (
FA325F1B4E7D8FFDF19A5C4A /* VniDrop.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -87,7 +87,7 @@
packageProductDependencies = (
);
productName = iosApp;
productReference = FA325F1B4E7D8FFDF19A5C4A /* vnidrop.app */;
productReference = FA325F1B4E7D8FFDF19A5C4A /* VniDrop.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */

84
packaging/linux/README.md Normal file
View File

@@ -0,0 +1,84 @@
# Linux packaging
VniDrop ships native x64 packages for the two common Linux package families:
- Debian/Ubuntu: `.deb`
- Current Fedora systems: `.rpm`
Both packages contain the application, its release Rust library, and a private
Java runtime. Users do not need to install Java separately. The packages are
currently distributed as direct GitHub Release downloads, so they use SHA-256
checksums rather than a Linux repository signing key. A future APT or RPM
repository should add repository metadata signing and its own update channel.
## GitHub Actions
The Linux packages workflow runs for relevant pull requests, release tags
matching `vMAJOR.MINOR.PATCH`, and manual dispatches. Each native package is
built and validated on its matching distribution family:
- `.deb` on Ubuntu 22.04 for a conservative glibc baseline
- `.rpm` inside Fedora 43 so `jpackage` can discover normal RPM dependencies
The shared JVM suite runs inside the Debian build job. Package construction and
payload validation happen in both build jobs, so there is no separate test
runner. Pull requests build and verify both packages but do not retain
artifacts. Manual runs retain build artifacts for 14 days. A pushed version tag
whose commit is on `master` creates the matching GitHub Release with the `.deb`,
`.rpm`, and a combined `SHA256SUMS`.
The existing `v1.0.0` tag predates this workflow and will not run it
retroactively. Use the next version tag after this configuration reaches
`master`.
## Install a downloaded package
Verify downloads from the directory containing all three release files:
```bash
sha256sum -c SHA256SUMS
```
On Debian or Ubuntu:
```bash
sudo apt install ./vnidrop_VERSION-1_amd64.deb
```
On Fedora:
```bash
sudo dnf install ./vnidrop-VERSION-1.x86_64.rpm
```
The package manager installs declared system-library dependencies and creates
the VniDrop desktop launcher. Uninstall with `sudo apt remove vnidrop` or
`sudo dnf remove vnidrop`.
## Manual native builds
Use JDK 21 and Rust 1.91. Build DEB packages on Debian/Ubuntu with `dpkg` and
`fakeroot`; build RPM packages on Fedora with `rpm-build`. Building an RPM on
Ubuntu prevents `jpackage` from discovering normal RPM dependencies.
From the repository root on the matching Linux family, run one of:
```bash
./gradlew :shared:jvmTest :desktopApp:packageReleaseDeb \
-Pvnidrop.version=1.0.0 \
-Pvnidrop.desktop.rustVariant=release \
-Pvnidrop.diagnostics.included=false \
--no-daemon --no-configuration-cache --stacktrace
./gradlew :shared:jvmTest :desktopApp:packageReleaseRpm \
-Pvnidrop.version=1.0.0 \
-Pvnidrop.desktop.rustVariant=release \
-Pvnidrop.diagnostics.included=false \
--no-daemon --no-configuration-cache --stacktrace
```
Compose writes the packages under
`desktopApp/build/compose/binaries/main-release/deb/` and
`desktopApp/build/compose/binaries/main-release/rpm/`. The workflow then
validates package identity, version, architecture, dependencies, bundled JVM,
and release Rust payload before publishing anything.

View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
version=${1:-1.0.0}
if [[ ${GITHUB_REF_TYPE:-} == "tag" ]]; then
if [[ ! ${GITHUB_REF_NAME:-} =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Linux release tags must use vMAJOR.MINOR.PATCH" >&2
exit 1
fi
version=${GITHUB_REF_NAME#v}
fi
if [[ ! $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Version must use MAJOR.MINOR.PATCH" >&2
exit 1
fi
IFS=. read -r major minor patch <<< "$version"
parts=("$major" "$minor" "$patch")
for index in "${!parts[@]}"; do
part=${parts[$index]}
if [[ $part != "0" && $part == 0* ]]; then
echo "Version components must be canonical integers without leading zeroes" >&2
exit 1
fi
if (( ${#part} > 5 )) || (( 10#$part > 65535 )); then
echo "Version components must be between 0 and 65535" >&2
exit 1
fi
if (( index == 0 && 10#$part == 0 )); then
echo "The major version must be non-zero" >&2
exit 1
fi
done
printf '%s\n' "$version"

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env bash
set -euo pipefail
if (( $# != 3 )); then
echo "Usage: $0 <deb|rpm> <MAJOR.MINOR.PATCH> <package-path>" >&2
exit 2
fi
format=$1
version=$2
package_path=$3
fail() {
echo "Package verification failed: $*" >&2
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "required command '$1' is not installed"
}
[[ -f $package_path ]] || fail "package not found: $package_path"
require_command find
require_command unzip
extract_root=$(mktemp -d)
trap 'rm -rf "$extract_root"' EXIT
case $format in
deb)
require_command dpkg-deb
[[ $(dpkg-deb -f "$package_path" Package) == "vnidrop" ]] || fail "unexpected Debian package name"
[[ $(dpkg-deb -f "$package_path" Version) == "$version-1" ]] || fail "unexpected Debian package version"
[[ $(dpkg-deb -f "$package_path" Architecture) == "amd64" ]] || fail "unexpected Debian architecture"
[[ $(dpkg-deb -f "$package_path" Maintainer) == *"support@sudosy.fr"* ]] || fail "unexpected Debian maintainer"
deb_dependencies=$(dpkg-deb -f "$package_path" Depends)
deb_libc_pattern='(^|,[[:space:]])libc6([[:space:](]|,|$)'
deb_xdg_pattern='(^|,[[:space:]])xdg-utils([[:space:](]|,|$)'
[[ $deb_dependencies =~ $deb_libc_pattern ]] || fail "Debian package does not declare libc6"
[[ $deb_dependencies =~ $deb_xdg_pattern ]] || fail "Debian package does not declare xdg-utils"
dpkg-deb -x "$package_path" "$extract_root"
;;
rpm)
require_command rpm
require_command rpm2cpio
require_command cpio
metadata=$(rpm -qp --queryformat '%{NAME}\n%{VERSION}\n%{RELEASE}\n%{ARCH}\n%{LICENSE}\n' "$package_path")
mapfile -t fields <<< "$metadata"
[[ ${fields[0]:-} == "vnidrop" ]] || fail "unexpected RPM package name"
[[ ${fields[1]:-} == "$version" ]] || fail "unexpected RPM package version"
[[ ${fields[2]:-} == "1" ]] || fail "unexpected RPM release"
[[ ${fields[3]:-} == "x86_64" ]] || fail "unexpected RPM architecture"
[[ ${fields[4]:-} == "Apache-2.0" ]] || fail "unexpected RPM license"
mapfile -t runtime_requirements < <(rpm -qpR "$package_path" | grep -Ev '^(rpmlib\(|/bin/sh$)' || true)
printf '%s\n' "${runtime_requirements[@]}" | grep -Eq '^(glibc($|[[:space:]])|libc\.so\.6)' || fail "RPM package does not declare glibc"
printf '%s\n' "${runtime_requirements[@]}" | grep -Fxq 'xdg-utils' || fail "RPM package does not declare xdg-utils"
rpm2cpio "$package_path" | (
cd "$extract_root"
cpio -idm --quiet
)
;;
*)
fail "unsupported package format: $format"
;;
esac
mapfile -d '' -t launchers < <(find "$extract_root" -type f -iname 'vnidrop' -perm /111 -print0)
(( ${#launchers[@]} == 1 )) || fail "expected exactly one executable VniDrop launcher"
mapfile -d '' -t desktop_entries < <(find "$extract_root" -type f -name '*.desktop' -print0)
(( ${#desktop_entries[@]} == 1 )) || fail "expected exactly one desktop entry"
grep -Eiq '^Exec=.*/VniDrop([[:space:]]|$)' "${desktop_entries[0]}" || fail "desktop entry does not launch VniDrop"
grep -Eq '^MimeType=.*application/vnd\.vnidrop\.transfer(;|$)' "${desktop_entries[0]}" || fail "desktop entry does not register VniDrop invitations"
mapfile -d '' -t bundled_jvms < <(find "$extract_root" -type f -path '*/lib/runtime/lib/server/libjvm.so' -print0)
(( ${#bundled_jvms[@]} == 1 )) || fail "expected exactly one bundled JVM"
mapfile -d '' -t debug_rust_jars < <(find "$extract_root" -type f -name 'shared-linux-x86-64-debug-*.jar' -print0)
(( ${#debug_rust_jars[@]} == 0 )) || fail "package contains a debug Rust runtime JAR"
mapfile -d '' -t rust_jars < <(
find "$extract_root" -type f -name 'shared-linux-x86-64-*.jar' ! -name 'shared-linux-x86-64-debug-*.jar' -print0
)
(( ${#rust_jars[@]} == 1 )) || fail "expected exactly one release Rust runtime JAR"
native_entry_size=$(unzip -p "${rust_jars[0]}" 'linux-x86-64/libvnidrop.so' | wc -c)
[[ $native_entry_size =~ ^[0-9]+$ ]] || fail "release Rust runtime JAR does not contain libvnidrop.so"
(( native_entry_size > 0 )) || fail "release Rust runtime JAR contains an empty libvnidrop.so"
mapfile -d '' -t shared_jars < <(find "$extract_root" -type f -name 'shared-jvm-*.jar' -print0)
(( ${#shared_jars[@]} == 1 )) || fail "expected exactly one shared JVM JAR"
unzip -p "${shared_jars[0]}" META-INF/MANIFEST.MF | tr -d '\r' |
grep -Fxq "Implementation-Version: $version" || fail "packaged app version does not match $version"
printf 'Verified %s package: %s\n' "$format" "$package_path"

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:uap10="http://schemas.microsoft.com/appx/manifest/uap/windows10/10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap uap10 rescap">
<Identity
Name="SudosyLabs.Vnidrop"
Publisher="CN=6456DC8E-2C31-44BD-AACC-2E6813C833CB"
Version="__VERSION__"
ProcessorArchitecture="x64" />
<Properties>
<DisplayName>Vnidrop</DisplayName>
<PublisherDisplayName>Sudosy Labs</PublisherDisplayName>
<Description>Send files directly across your devices.</Description>
<Logo>Assets\StoreLogo.png</Logo>
</Properties>
<Resources>
<Resource Language="en-US" />
</Resources>
<Dependencies>
<TargetDeviceFamily
Name="Windows.Desktop"
MinVersion="10.0.19041.0"
MaxVersionTested="10.0.26100.0" />
</Dependencies>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
</Capabilities>
<Applications>
<Application
Id="VniDrop"
Executable="VniDrop.exe"
uap10:RuntimeBehavior="packagedClassicApp"
uap10:TrustLevel="mediumIL">
<uap:VisualElements
DisplayName="Vnidrop"
Description="Send files directly across your devices."
Square150x150Logo="Assets\Square150x150Logo.png"
Square44x44Logo="Assets\Square44x44Logo.png"
BackgroundColor="transparent" />
<Extensions>
<uap:Extension Category="windows.fileTypeAssociation">
<uap:FileTypeAssociation Name="vnidropinvitation">
<uap:DisplayName>VniDrop Invitation</uap:DisplayName>
<uap:Logo>Assets\Square44x44Logo.png</uap:Logo>
<uap:SupportedFileTypes>
<uap:FileType>.vnd</uap:FileType>
</uap:SupportedFileTypes>
</uap:FileTypeAssociation>
</uap:Extension>
</Extensions>
</Application>
</Applications>
</Package>

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

105
packaging/windows/README.md Normal file
View File

@@ -0,0 +1,105 @@
# Windows Microsoft Store packaging
This directory turns the Compose Desktop Windows app image into the unsigned
MSIX artifacts accepted by Partner Center. Microsoft signs the package after
certification, so this build does not use a PFX, certificate, HSM, or signing
secret.
## Product identity
These values came from the Partner Center Product identity page and are
case-sensitive:
| Field | Value |
| --- | --- |
| Package identity name | SudosyLabs.Vnidrop |
| Publisher | CN=6456DC8E-2C31-44BD-AACC-2E6813C833CB |
| Publisher display name | Sudosy Labs |
| Reserved Store name | Vnidrop |
| Application ID | VniDrop |
| Store ID | 9NJ5Q0FG7TGL |
The package identity name, publisher, and Application ID must remain stable
after the first release. The manifest display name uses the exact reserved Store
name; the product's in-app branding and launcher remain `VniDrop`.
The initial package targets Windows Desktop x64, Windows 10 version 2004
(build 19041) or later. The fourth MSIX version component is reserved by the
Store, so app version 1.2.3 becomes package version 1.2.3.0.
## GitHub Actions
The Windows Store package workflow runs automatically for relevant pull
requests, for release tags matching vMAJOR.MINOR.PATCH, and by manual dispatch.
Pull requests build and validate without retaining an artifact. Tags and manual
runs retain:
- VniDrop_VERSION_x64.msix
- VniDrop_VERSION_x64.msixupload
- build metadata
- SHA-256 checksums
The preferred Partner Center upload is the msixupload file. It is an upload
envelope containing the x64 MSIX. The MSIX is intentionally unsigned and is not
a public sideloading artifact. Do not attach it to a public GitHub Release
unless an independent production-signing path is added.
The workflow explicitly selects Gobley's release Rust variant and rejects a
package containing the debug native JAR. It also verifies the bundled JVM,
vnidrop.dll, app version, manifest identity, architecture, and launcher after
MakeAppx unpacks the finished package.
## First Store release
Microsoft's current GitHub Actions publishing flow is for updates to an
already-live free product. For the first release:
1. Run this workflow from a release tag or by manual dispatch.
2. Download the retained artifact.
3. Test that exact build on an interactive Windows VM. Local installation needs
an ephemeral development signature trusted only by that VM; this is not a
production signing key.
4. Upload the msixupload file to the current Partner Center draft.
5. Confirm that Partner Center parses the expected identity, version, x64
architecture, Windows.Desktop target, en-US language, and runFullTrust
capability.
6. Complete listing, screenshots, certification notes, and submit.
Use this restricted-capability justification in Submission options:
> VniDrop is a classic JVM desktop application that loads its bundled native
> Rust and JVM libraries and needs normal user-level filesystem and network
> access to transfer user-selected files directly between devices.
After the first release is certified and live, Store publication can be added
as a separate protected job. Keep its Partner Center credentials in a GitHub
Environment, not in this build job:
- AZURE_AD_TENANT_ID
- AZURE_AD_APPLICATION_CLIENT_ID
- AZURE_AD_APPLICATION_SECRET
- SELLER_ID
The Store ID is a non-secret variable.
## Manual build on Windows
From the repository root:
~~~powershell
.\gradlew.bat :shared:jvmTest :desktopApp:createReleaseDistributable -Pvnidrop.version=1.0.0 -Pvnidrop.desktop.rustVariant=release -Pvnidrop.diagnostics.included=false --no-daemon --no-configuration-cache --stacktrace
.\packaging\windows\build-msix.ps1 -Version 1.0.0 -AppImage .\desktopApp\build\compose\binaries\main-release\app\VniDrop -OutputDirectory .\build\release\windows
~~~
The packaging script requires Windows SDK 10.0.26100.0. It uses MakePri to
index the scale-qualified visual assets, then MakeAppx with SHA-256 block maps
and manifest validation enabled.
Microsoft references:
- [MSIX Store package requirements](https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/app-package-requirements)
- [Manual desktop MSIX packaging](https://learn.microsoft.com/en-us/windows/msix/desktop/desktop-to-uwp-manual-conversion)
- [MakeAppx](https://learn.microsoft.com/en-us/windows/msix/package/create-app-package-with-makeappx-tool)
- [Uploading MSIX packages](https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/upload-app-packages)
- [GitHub Actions Store updates](https://learn.microsoft.com/en-us/windows/apps/publish/msstore-dev-cli/github-actions)

View File

@@ -0,0 +1,280 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string] $Version,
[Parameter(Mandatory)]
[string] $AppImage,
[Parameter(Mandatory)]
[string] $OutputDirectory,
[string] $WindowsSdkVersion = "10.0.26100.0"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
function Assert-Condition {
param(
[bool] $Condition,
[string] $Message
)
if (-not $Condition) {
throw $Message
}
}
function Invoke-Checked {
param(
[string] $FilePath,
[string[]] $Arguments
)
& $FilePath @Arguments
if ($LASTEXITCODE -ne 0) {
throw "$FilePath failed with exit code $LASTEXITCODE"
}
}
function Read-ZipEntry {
param(
[string] $ArchivePath,
[string] $EntryPath
)
$archive = [System.IO.Compression.ZipFile]::OpenRead($ArchivePath)
try {
$entry = $archive.GetEntry($EntryPath)
if ($null -eq $entry) {
throw "$ArchivePath does not contain $EntryPath"
}
$reader = [System.IO.StreamReader]::new($entry.Open())
try {
return $reader.ReadToEnd()
}
finally {
$reader.Dispose()
}
}
finally {
$archive.Dispose()
}
}
function Get-ZipEntryLength {
param(
[string] $ArchivePath,
[string] $EntryPath
)
$archive = [System.IO.Compression.ZipFile]::OpenRead($ArchivePath)
try {
$entry = $archive.GetEntry($EntryPath)
if ($null -eq $entry) {
throw "$ArchivePath does not contain $EntryPath"
}
return $entry.Length
}
finally {
$archive.Dispose()
}
}
if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) {
throw "MSIX packaging must run on Windows"
}
$versionParts = $Version.Split(".")
Assert-Condition ($versionParts.Count -eq 3) "Version must use MAJOR.MINOR.PATCH"
for ($index = 0; $index -lt $versionParts.Count; $index++) {
$part = $versionParts[$index]
$number = 0
Assert-Condition ([int]::TryParse($part, [ref] $number)) "Version components must be integers"
Assert-Condition ($number.ToString() -eq $part) "Version components must not contain leading zeroes"
Assert-Condition ($number -ge $(if ($index -eq 0) { 1 } else { 0 }) -and $number -le 65535) "Version components must be between 0 and 65535, with a non-zero major"
}
$packageVersion = "$Version.0"
$appImagePath = (Resolve-Path -LiteralPath $AppImage).Path
Assert-Condition (Test-Path -LiteralPath $appImagePath -PathType Container) "App image not found: $AppImage"
Assert-Condition (Test-Path -LiteralPath (Join-Path $appImagePath "VniDrop.exe") -PathType Leaf) "The app image does not contain VniDrop.exe"
Assert-Condition (Test-Path -LiteralPath (Join-Path $appImagePath "runtime\bin\server\jvm.dll") -PathType Leaf) "The app image does not contain its bundled JVM"
Add-Type -AssemblyName System.IO.Compression.FileSystem
$appFiles = @(Get-ChildItem -LiteralPath $appImagePath -Recurse -File)
$debugRustJars = @($appFiles | Where-Object { $_.Name -match "^shared-win32-x86-64-debug-.+\.jar$" })
Assert-Condition ($debugRustJars.Count -eq 0) "The app image contains a debug Rust runtime JAR"
$releaseRustJars = @($appFiles | Where-Object { $_.Name -match "^shared-win32-x86-64-(?!debug-).+\.jar$" })
Assert-Condition ($releaseRustJars.Count -eq 1) "Expected exactly one release Rust runtime JAR"
$nativeDllLength = Get-ZipEntryLength -ArchivePath $releaseRustJars[0].FullName -EntryPath "win32-x86-64/vnidrop.dll"
Assert-Condition ($nativeDllLength -gt 0) "The release Rust runtime JAR contains an empty vnidrop.dll"
$sharedJars = @($appFiles | Where-Object { $_.Name -match "^shared-jvm-.+\.jar$" })
Assert-Condition ($sharedJars.Count -eq 1) "Expected exactly one shared JVM JAR"
$sharedManifest = Read-ZipEntry -ArchivePath $sharedJars[0].FullName -EntryPath "META-INF/MANIFEST.MF"
Assert-Condition ($sharedManifest -match "(?m)^Implementation-Version: $([regex]::Escape($Version))\r?$") "The packaged app version does not match $Version"
$programFilesX86 = [System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFilesX86)
$makeAppxPath = Join-Path $programFilesX86 "Windows Kits\10\bin\$WindowsSdkVersion\x64\MakeAppx.exe"
$makePriPath = Join-Path $programFilesX86 "Windows Kits\10\bin\$WindowsSdkVersion\x64\MakePri.exe"
Assert-Condition (Test-Path -LiteralPath $makeAppxPath -PathType Leaf) "MakeAppx.exe from Windows SDK $WindowsSdkVersion was not found"
Assert-Condition (Test-Path -LiteralPath $makePriPath -PathType Leaf) "MakePri.exe from Windows SDK $WindowsSdkVersion was not found"
$outputPath = [System.IO.Path]::GetFullPath($OutputDirectory)
[System.IO.Directory]::CreateDirectory($outputPath) | Out-Null
$artifactBaseName = "VniDrop_" + $Version + "_x64"
$msixPath = Join-Path $outputPath "$artifactBaseName.msix"
$uploadPath = Join-Path $outputPath "$artifactBaseName.msixupload"
$buildInfoPath = Join-Path $outputPath "$artifactBaseName.build-info.json"
$checksumsPath = Join-Path $outputPath "SHA256SUMS"
@($msixPath, $uploadPath, $buildInfoPath, $checksumsPath) |
Where-Object { Test-Path -LiteralPath $_ } |
ForEach-Object { Remove-Item -LiteralPath $_ -Force }
$stageRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("vnidrop-msix-" + [System.Guid]::NewGuid().ToString("N"))
$packageRoot = Join-Path $stageRoot "package"
$unpackedRoot = Join-Path $stageRoot "unpacked"
$priConfigPath = Join-Path $stageRoot "priconfig.xml"
[System.IO.Directory]::CreateDirectory($packageRoot) | Out-Null
try {
Get-ChildItem -LiteralPath $appImagePath -Force | Copy-Item -Destination $packageRoot -Recurse -Force
Copy-Item -LiteralPath (Join-Path $PSScriptRoot "Assets") -Destination $packageRoot -Recurse -Force
$manifestTemplate = Get-Content -LiteralPath (Join-Path $PSScriptRoot "AppxManifest.xml") -Raw
Assert-Condition (([regex]::Matches($manifestTemplate, "__VERSION__")).Count -eq 1) "AppxManifest.xml must contain exactly one __VERSION__ placeholder"
$manifestText = $manifestTemplate.Replace("__VERSION__", $packageVersion)
[System.IO.File]::WriteAllText(
(Join-Path $packageRoot "AppxManifest.xml"),
$manifestText,
[System.Text.UTF8Encoding]::new($false)
)
Invoke-Checked -FilePath $makePriPath -Arguments @(
"createconfig",
"/cf", $priConfigPath,
"/dq", "en-US",
"/o"
)
Invoke-Checked -FilePath $makePriPath -Arguments @(
"new",
"/pr", $packageRoot,
"/cf", $priConfigPath,
"/mn", (Join-Path $packageRoot "AppxManifest.xml"),
"/of", (Join-Path $packageRoot "resources.pri"),
"/o"
)
Assert-Condition ((Get-Item -LiteralPath (Join-Path $packageRoot "resources.pri")).Length -gt 0) "MakePri created an empty resources.pri"
Invoke-Checked -FilePath $makeAppxPath -Arguments @(
"pack",
"/v",
"/h", "SHA256",
"/d", $packageRoot,
"/p", $msixPath,
"/o"
)
Invoke-Checked -FilePath $makeAppxPath -Arguments @(
"unpack",
"/v",
"/p", $msixPath,
"/d", $unpackedRoot,
"/o"
)
[xml] $manifest = Get-Content -LiteralPath (Join-Path $unpackedRoot "AppxManifest.xml") -Raw
$namespaces = [System.Xml.XmlNamespaceManager]::new($manifest.NameTable)
$namespaces.AddNamespace("f", "http://schemas.microsoft.com/appx/manifest/foundation/windows10")
$namespaces.AddNamespace("uap", "http://schemas.microsoft.com/appx/manifest/uap/windows10")
$namespaces.AddNamespace("uap10", "http://schemas.microsoft.com/appx/manifest/uap/windows10/10")
$namespaces.AddNamespace("rescap", "http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities")
$identity = $manifest.SelectSingleNode("/f:Package/f:Identity", $namespaces)
Assert-Condition ($null -ne $identity) "The packed manifest has no Identity"
Assert-Condition ($identity.GetAttribute("Name") -eq "SudosyLabs.Vnidrop") "The packed package identity name is incorrect"
Assert-Condition ($identity.GetAttribute("Publisher") -eq "CN=6456DC8E-2C31-44BD-AACC-2E6813C833CB") "The packed publisher identity is incorrect"
Assert-Condition ($identity.GetAttribute("Version") -eq $packageVersion) "The packed package version is incorrect"
Assert-Condition ($identity.GetAttribute("ProcessorArchitecture") -eq "x64") "The packed package architecture is not x64"
Assert-Condition ($manifest.SelectSingleNode("/f:Package/f:Properties/f:DisplayName", $namespaces).InnerText -eq "Vnidrop") "The packed display name does not match the reserved Store name"
Assert-Condition ($manifest.SelectSingleNode("/f:Package/f:Properties/f:PublisherDisplayName", $namespaces).InnerText -eq "Sudosy Labs") "The packed publisher display name is incorrect"
$targetFamily = $manifest.SelectSingleNode("/f:Package/f:Dependencies/f:TargetDeviceFamily", $namespaces)
Assert-Condition ($null -ne $targetFamily) "The packed manifest has no target device family"
Assert-Condition ($targetFamily.GetAttribute("Name") -eq "Windows.Desktop") "The packed package does not target Windows.Desktop"
Assert-Condition ($null -ne $manifest.SelectSingleNode("/f:Package/f:Capabilities/rescap:Capability[@Name='runFullTrust']", $namespaces)) "The packed package does not declare runFullTrust"
$application = $manifest.SelectSingleNode("/f:Package/f:Applications/f:Application", $namespaces)
Assert-Condition ($null -ne $application) "The packed manifest has no Application"
Assert-Condition ($application.GetAttribute("Id") -eq "VniDrop") "The packed application ID is incorrect"
$executable = $application.GetAttribute("Executable")
Assert-Condition ($executable -eq "VniDrop.exe") "The packed manifest executable is incorrect"
Assert-Condition ($application.GetAttribute("RuntimeBehavior", "http://schemas.microsoft.com/appx/manifest/uap/windows10/10") -eq "packagedClassicApp") "The packed runtime behavior is incorrect"
Assert-Condition ($application.GetAttribute("TrustLevel", "http://schemas.microsoft.com/appx/manifest/uap/windows10/10") -eq "mediumIL") "The packed trust level is incorrect"
Assert-Condition ($manifest.SelectSingleNode("/f:Package/f:Applications/f:Application/f:Extensions/uap:Extension/uap:FileTypeAssociation/uap:SupportedFileTypes/uap:FileType[text()='.vnd']", $namespaces) -ne $null) "The packed package is missing the .vnd file association"
Assert-Condition (Test-Path -LiteralPath (Join-Path $unpackedRoot $executable) -PathType Leaf) "The packed executable is missing"
Assert-Condition (Test-Path -LiteralPath (Join-Path $unpackedRoot "resources.pri") -PathType Leaf) "The packed resource index is missing"
}
finally {
if (Test-Path -LiteralPath $stageRoot) {
Remove-Item -LiteralPath $stageRoot -Recurse -Force
}
}
$temporaryZip = Join-Path $outputPath "$artifactBaseName.zip"
if (Test-Path -LiteralPath $temporaryZip) {
Remove-Item -LiteralPath $temporaryZip -Force
}
Compress-Archive -LiteralPath $msixPath -DestinationPath $temporaryZip -CompressionLevel Optimal
Move-Item -LiteralPath $temporaryZip -Destination $uploadPath
$repoRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..\..")).Path
$wrapperProperties = Get-Content -LiteralPath (Join-Path $repoRoot "gradle\wrapper\gradle-wrapper.properties")
$gradleDistribution = $wrapperProperties | Where-Object { $_.StartsWith("distributionUrl=") } | Select-Object -First 1
$sourceCommit = [System.Environment]::GetEnvironmentVariable("GITHUB_SHA")
if ([string]::IsNullOrWhiteSpace($sourceCommit)) {
$sourceCommit = "local"
}
$sourceRef = [System.Environment]::GetEnvironmentVariable("GITHUB_REF")
if ([string]::IsNullOrWhiteSpace($sourceRef)) {
$sourceRef = "local"
}
$buildInfo = [ordered] @{
appVersion = $Version
packageVersion = $packageVersion
architecture = "x64"
identityName = "SudosyLabs.Vnidrop"
publisher = "CN=6456DC8E-2C31-44BD-AACC-2E6813C833CB"
storeId = "9NJ5Q0FG7TGL"
sourceCommit = $sourceCommit
sourceRef = $sourceRef
runnerImage = [System.Environment]::GetEnvironmentVariable("ImageOS")
runnerImageVersion = [System.Environment]::GetEnvironmentVariable("ImageVersion")
javaVersion = ((& java --version | Select-Object -First 1) | Out-String).Trim()
rustVersion = ((& rustc --version) | Out-String).Trim()
cargoVersion = ((& cargo --version) | Out-String).Trim()
gradleDistribution = $gradleDistribution
windowsSdkVersion = $WindowsSdkVersion
makeAppxVersion = (Get-Item -LiteralPath $makeAppxPath).VersionInfo.FileVersion
makePriVersion = (Get-Item -LiteralPath $makePriPath).VersionInfo.FileVersion
unsignedForMicrosoftStore = $true
builtAtUtc = [System.DateTimeOffset]::UtcNow.ToString("O")
}
[System.IO.File]::WriteAllText(
$buildInfoPath,
($buildInfo | ConvertTo-Json -Depth 4),
[System.Text.UTF8Encoding]::new($false)
)
$checksumTargets = @($msixPath, $uploadPath, $buildInfoPath)
[string[]] $checksumLines = $checksumTargets | ForEach-Object {
$hash = Get-FileHash -LiteralPath $_ -Algorithm SHA256
$hash.Hash.ToLowerInvariant() + " " + [System.IO.Path]::GetFileName($_)
}
[System.IO.File]::WriteAllLines($checksumsPath, $checksumLines, [System.Text.Encoding]::ASCII)
Write-Host "Created unsigned Microsoft Store artifacts:"
Write-Host " $msixPath"
Write-Host " $uploadPath"
Write-Host " $checksumsPath"

View File

@@ -2,6 +2,7 @@
"name": "vnidrop-diagnostics-api",
"private": true,
"version": "0.1.0",
"license": "Apache-2.0",
"type": "module",
"engines": {
"node": ">=22.12"

View File

@@ -8,11 +8,13 @@ import gobley.gradle.GobleyHost
import gobley.gradle.rust.targets.RustAndroidTarget
import gobley.gradle.rust.targets.RustAppleMobileTarget
import gobley.gradle.rust.targets.RustTarget
import gobley.gradle.Variant
import org.gradle.api.DefaultTask
import org.gradle.api.provider.ListProperty
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import org.gradle.jvm.tasks.Jar
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
abstract class VerifyHostCargoTaskSelection : DefaultTask() {
@@ -37,16 +39,27 @@ plugins {
alias(libs.plugins.kotlinAtomicfu)
}
val appVersion = providers.gradleProperty("vnidrop.version").get()
val desktopRustVariant = providers.gradleProperty("vnidrop.desktop.rustVariant")
.map { value ->
when (value.trim().lowercase()) {
"debug" -> Variant.Debug
"release" -> Variant.Release
else -> error("vnidrop.desktop.rustVariant must be either debug or release")
}
}
.orElse(Variant.Debug)
// Compile-time switches (gradle.properties or -P…).
// included=false: no Share-diagnostics toggle, no telemetry/crash auto-upload stack.
// endpoint/key both empty: transport is NoOp (safe default until Cloudflare is deployed).
val diagnosticsIncluded: Boolean =
(findProperty("vnidrop.diagnostics.included") as String?)?.toBooleanStrictOrNull() ?: true
(findProperty("vnidrop.diagnostics.included") as String?)?.toBooleanStrictOrNull() ?: false
val diagnosticsEndpoint: String =
(findProperty("vnidrop.diagnostics.endpoint") as String?)?.trim().orEmpty()
val diagnosticsIngestKey: String =
(findProperty("vnidrop.diagnostics.ingestKey") as String?)?.trim().orEmpty()
check(diagnosticsEndpoint.isEmpty() == diagnosticsIngestKey.isEmpty()) {
check(!diagnosticsIncluded || diagnosticsEndpoint.isEmpty() == diagnosticsIngestKey.isEmpty()) {
"vnidrop.diagnostics.endpoint and vnidrop.diagnostics.ingestKey must be configured together"
}
@@ -56,8 +69,8 @@ val generateDiagnosticsBuildConfig by tasks.registering {
description = "Generates DiagnosticsBuildConfig from vnidrop.diagnostics.* properties"
val outputDir = diagnosticsBuildConfigDir
val included = diagnosticsIncluded
val endpoint = diagnosticsEndpoint
val ingestKey = diagnosticsIngestKey
val endpoint = if (included) diagnosticsEndpoint else ""
val ingestKey = if (included) diagnosticsIngestKey else ""
inputs.property("vnidrop.diagnostics.included", included)
inputs.property("vnidrop.diagnostics.endpoint", endpoint)
inputs.property("vnidrop.diagnostics.ingestKey", ingestKey)
@@ -185,6 +198,7 @@ val hostCargoTargets = buildSet<RustTarget> {
cargo {
packageDirectory = layout.projectDirectory.dir("../crates/vnidrop")
publishJvmArtifacts = true
jvmVariant.set(desktopRustVariant)
androidTargetsToBuild.set(setOf(RustAndroidTarget.Arm64, RustAndroidTarget.X64))
builds.jvm {
variants {
@@ -219,9 +233,15 @@ cargo {
uniffi {
generateFromLibrary {
namespace = "vnidrop"
build.set(GobleyHost.current.rustTarget)
variant.set(desktopRustVariant)
}
}
tasks.named<Jar>("jvmJar") {
manifest.attributes["Implementation-Version"] = appVersion
}
val verifyHostCargoTaskSelection = tasks.register<VerifyHostCargoTaskSelection>(
"verifyHostCargoTaskSelection"
) {

View File

@@ -18,13 +18,14 @@ class DiagnosticsCoordinator(
val crashReporter: CrashReporter,
val bugReports: BugReportService,
private val scope: CoroutineScope,
private val included: Boolean = DiagnosticsBuildConfig.INCLUDED,
) {
fun start() {
// Install id is useful for bug-report correlation even without telemetry.
scope.launch {
preferencesRepository.ensureDiagnosticsInstallId()
}
if (!DiagnosticsBuildConfig.INCLUDED) return
if (!included) return
crashReporter.startObservingPreferences()
crashReporter.installUnhandledExceptionHandler()
scope.launch {
@@ -33,7 +34,7 @@ class DiagnosticsCoordinator(
}
fun record(name: String, properties: Map<String, String> = emptyMap()) {
if (!DiagnosticsBuildConfig.INCLUDED) return
if (!included) return
telemetry.record(name, properties)
}
@@ -45,6 +46,7 @@ class DiagnosticsCoordinator(
preferencesRepository: PreferencesRepository,
scope: CoroutineScope,
transport: DiagnosticsTransport = NoOpDiagnosticsTransport(),
included: Boolean = DiagnosticsBuildConfig.INCLUDED,
): DiagnosticsCoordinator {
val breadcrumbs = BreadcrumbBuffer()
val crashStore = createPendingCrashStore(appDataDir)
@@ -53,6 +55,7 @@ class DiagnosticsCoordinator(
transport = transport,
breadcrumbs = breadcrumbs,
scope = scope,
included = included,
)
val crashReporter = CrashReporter(
store = crashStore,
@@ -78,6 +81,7 @@ class DiagnosticsCoordinator(
crashReporter = crashReporter,
bugReports = bugReports,
scope = scope,
included = included,
)
}
}

View File

@@ -119,6 +119,7 @@ fun createDiagnosticsTransport(
platform: String,
installIdProvider: suspend () -> String,
): DiagnosticsTransport = buildDiagnosticsTransport(
included = DiagnosticsBuildConfig.INCLUDED,
endpoint = DiagnosticsBuildConfig.ENDPOINT,
ingestKey = DiagnosticsBuildConfig.INGEST_KEY,
appVersion = appVersion,
@@ -127,12 +128,14 @@ fun createDiagnosticsTransport(
)
internal fun buildDiagnosticsTransport(
included: Boolean = true,
endpoint: String,
ingestKey: String,
appVersion: String,
platform: String,
installIdProvider: suspend () -> String,
): DiagnosticsTransport {
if (!included) return NoOpDiagnosticsTransport()
val normalizedEndpoint = endpoint.trim()
val normalizedIngestKey = ingestKey.trim()
if (normalizedEndpoint.isEmpty() && normalizedIngestKey.isEmpty()) return NoOpDiagnosticsTransport()

View File

@@ -31,6 +31,7 @@ class TelemetryRecorder(
private val flushIntervalMillis: Long = DefaultFlushIntervalMillis,
private val retryBackoffMillis: Long = DefaultRetryBackoffMillis,
private val automaticRetryCount: Int = DefaultAutomaticRetryCount,
private val included: Boolean = true,
) {
private val bufferMutex = Mutex()
private val state = AtomicReference(TelemetryState())
@@ -42,22 +43,24 @@ class TelemetryRecorder(
require(flushIntervalMillis > 0) { "flushIntervalMillis must be positive" }
require(retryBackoffMillis > 0) { "retryBackoffMillis must be positive" }
require(automaticRetryCount >= 0) { "automaticRetryCount must not be negative" }
scope.launch {
preferencesRepository.preferences
.map { it.diagnosticsEnabled }
.distinctUntilChanged()
.collect { isEnabled ->
updateState { current ->
if (isEnabled) current.copy(enabled = true) else TelemetryState(enabled = false)
if (included) {
scope.launch {
preferencesRepository.preferences
.map { it.diagnosticsEnabled }
.distinctUntilChanged()
.collect { isEnabled ->
updateState { current ->
if (isEnabled) current.copy(enabled = true) else TelemetryState(enabled = false)
}
flushSignals.trySend(Unit)
}
flushSignals.trySend(Unit)
}
}
scope.launch { runAutomaticFlushes() }
}
scope.launch { runAutomaticFlushes() }
}
fun record(name: String, properties: Map<String, String> = emptyMap()) {
if (!DiagnosticsBuildConfig.INCLUDED) return
if (!included) return
val sanitizedName = sanitizeDiagnosticName(name)
if (sanitizedName.isBlank()) return
val sanitizedProperties = sanitizeDiagnosticProperties(properties)

View File

@@ -88,6 +88,7 @@ class SettingsViewModel(
private val messages: UiMessageController,
private val bugReports: BugReportService,
private val diagnostics: DiagnosticsCoordinator? = null,
private val diagnosticsIncluded: Boolean = DiagnosticsBuildConfig.INCLUDED,
) : ViewModel() {
private val _state = MutableStateFlow(
SettingsState(
@@ -101,18 +102,16 @@ class SettingsViewModel(
val effectFlow = effects.receiveAsFlow()
private var enableNotificationsAfterSettings = false
private var usernamePersistJob: Job? = null
private var hasLocalUsernameDraft = false
init {
viewModelScope.launch {
preferencesRepository.preferences.collect { preferences ->
val previousFolder = _state.value.receiveFolder
val receiveFolder = fileSystemService.effectiveReceiveFolder(preferences.receiveFolder)
// While the user is typing, keep the in-progress value. DataStore
// echoes can race keystrokes and trim trailing spaces mid-edit.
val editingUsername = usernamePersistJob?.isActive == true
_state.update { current ->
current.copy(
username = if (editingUsername) current.username else preferences.username,
username = if (hasLocalUsernameDraft) current.username else preferences.username,
receiveFolder = receiveFolder,
themeMode = preferences.themeMode,
notificationsEnabled = preferences.notificationsEnabled,
@@ -140,6 +139,7 @@ class SettingsViewModel(
}
fun setUsername(value: String) {
hasLocalUsernameDraft = true
_state.update { it.copy(username = value) }
usernamePersistJob?.cancel()
usernamePersistJob = viewModelScope.launch {
@@ -201,7 +201,7 @@ class SettingsViewModel(
}
fun setDiagnosticsEnabled(enabled: Boolean) {
if (!DiagnosticsBuildConfig.INCLUDED) return
if (!diagnosticsIncluded) return
viewModelScope.launch {
preferencesRepository.setDiagnosticsEnabled(enabled)
diagnostics?.record(

View File

@@ -273,6 +273,19 @@ class DiagnosticsTest {
assertIs<NoOpDiagnosticsTransport>(transport)
}
@Test
fun excludedTransportIgnoresIncompleteConfiguration() {
val transport = buildDiagnosticsTransport(
included = false,
endpoint = "https://diag.example",
ingestKey = "",
appVersion = "1.0",
platform = "Test",
installIdProvider = { "id" },
)
assertIs<NoOpDiagnosticsTransport>(transport)
}
@Test
fun noOpTransportReportsUnavailableDelivery() = runTest {
val result = NoOpDiagnosticsTransport().sendEvents(
@@ -329,6 +342,27 @@ class DiagnosticsTest {
assertEquals(1, breadcrumbs.snapshot().size)
}
@Test
fun excludedTelemetryDoesNotStartOrRecordEvents() = runTest {
val transport = RecordingDiagnosticsTransport()
val breadcrumbs = BreadcrumbBuffer()
val recorder = TelemetryRecorder(
preferencesRepository = fakePrefs(diagnosticsEnabled = true),
transport = transport,
breadcrumbs = breadcrumbs,
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
flushThreshold = 1,
included = false,
)
recorder.record("app_open")
advanceUntilIdle()
assertEquals(0, recorder.pendingCount())
assertTrue(transport.events.isEmpty())
assertTrue(breadcrumbs.snapshot().isEmpty())
}
@Test
fun telemetryRetainsColdStartEventsUntilConsentLoads() = runTest {
val backing = fakePrefs(diagnosticsEnabled = true)

View File

@@ -90,7 +90,7 @@ class ViewModelsTest {
}
@Test
fun settingsUsernameKeepsSpacesWhileTypingAndPersistsAfterDebounce() = runTest {
fun settingsUsernameDraftIsNotOverwrittenByPersistedEcho() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val preferences = preferences()
val viewModel = settingsViewModel(preferences)
@@ -104,7 +104,11 @@ class ViewModelsTest {
testScheduler.advanceTimeBy(400)
advanceUntilIdle()
assertEquals("Ada", preferences.mutablePreferences.value.username)
assertEquals("Ada", viewModel.state.value.username)
assertEquals("Ada ", viewModel.state.value.username)
preferences.setThemeMode(ThemeMode.Dark)
advanceUntilIdle()
assertEquals("Ada ", viewModel.state.value.username)
}
@Test
@@ -154,7 +158,7 @@ class ViewModelsTest {
fun settingsTogglesDiagnosticsPreference() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val preferences = preferences()
val viewModel = settingsViewModel(preferences)
val viewModel = settingsViewModel(preferences, diagnosticsIncluded = true)
advanceUntilIdle()
assertFalse(viewModel.state.value.diagnosticsEnabled)
viewModel.setDiagnosticsEnabled(true)
@@ -163,6 +167,20 @@ class ViewModelsTest {
assertTrue(viewModel.state.value.diagnosticsEnabled)
}
@Test
fun settingsIgnoresDiagnosticsOptInWhenExcluded() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val preferences = preferences()
val viewModel = settingsViewModel(preferences)
advanceUntilIdle()
viewModel.setDiagnosticsEnabled(true)
advanceUntilIdle()
assertFalse(preferences.mutablePreferences.value.diagnosticsEnabled)
assertFalse(viewModel.state.value.diagnosticsEnabled)
}
@Test
fun settingsSubmitsBugReportAndClearsForm() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
@@ -643,6 +661,7 @@ class ViewModelsTest {
notifications: FakeNotificationService = FakeNotificationService(),
transport: DiagnosticsTransport = RecordingDiagnosticsTransport(),
fileSystem: FakeFileSystemService = FakeFileSystemService(folder),
diagnosticsIncluded: Boolean = false,
) = SettingsViewModel(
environment(),
{ DeviceInfo("Device", "Model", "OS", "Wi-Fi", "80%") },
@@ -658,6 +677,7 @@ class ViewModelsTest {
platform = "Test",
logReader = { "sample log line" },
),
diagnosticsIncluded = diagnosticsIncluded,
)
private fun receivedTransfer(id: ULong, status: TransferStatus) = Transfer(