Merge with master
47
.github/workflows/docs.yml
vendored
Normal 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
@@ -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
@@ -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
@@ -22,3 +22,4 @@ target/
|
|||||||
|
|
||||||
# Local design export scratch
|
# Local design export scratch
|
||||||
output/
|
output/
|
||||||
|
.screenshots
|
||||||
|
|||||||
161
CODE_OF_CONDUCT.md
Normal 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
@@ -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
@@ -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
@@ -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 you’re sharing your UI with Compose Multiplatform,
|
<h1 align="center">VniDrop</h1>
|
||||||
you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project.
|
|
||||||
|
|
||||||
* [/shared](./shared/src) is for code that will be shared across your Compose Multiplatform applications.
|
<p align="center">
|
||||||
It contains several subfolders:
|
<strong>Send files directly. Stay in control of who receives them.</strong>
|
||||||
- [commonMain](./shared/src/commonMain/kotlin) is for code that’s common for all targets.
|
</p>
|
||||||
- Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name.
|
|
||||||
For example, if you want to use Apple’s CoreCrypto for the iOS part of your Kotlin app,
|
|
||||||
the [iosMain](./shared/src/iosMain/kotlin) folder would be the right place for such calls.
|
|
||||||
Similarly, if you want to edit the Desktop (JVM) specific part, the [jvmMain](./shared/src/jvmMain/kotlin)
|
|
||||||
folder is the appropriate location.
|
|
||||||
|
|
||||||
### 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
|
<p align="center">
|
||||||
options:
|
<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`
|
VniDrop moves files and folders from one device to another without first
|
||||||
- Desktop app:
|
uploading them to a file-hosting service. Choose what to send, decide who may
|
||||||
- Hot reload: `./gradlew :desktopApp:hotRun --auto`
|
receive it, and share a small invitation. The receiving device uses that
|
||||||
- Standard run: `./gradlew :desktopApp:run`
|
invitation to find the sender and request the files.
|
||||||
- iOS app: open the [/iosApp](./iosApp) directory in Xcode and run it from there.
|
|
||||||
|
|
||||||
### 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`
|
1. **Choose files or a folder.** VniDrop prepares the selection on the sender's
|
||||||
- Desktop tests: `./gradlew :shared:jvmTest`
|
device and keeps the original folder structure.
|
||||||
- iOS tests: `./gradlew :shared:iosSimulatorArm64Test`
|
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
@@ -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.
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">vnidrop</string>
|
<string name="app_name">VniDrop</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
name = "vnidrop"
|
name = "vnidrop"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "vnidrop"
|
name = "vnidrop"
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
mod support;
|
mod support;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Condvar, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::Duration;
|
||||||
|
|
||||||
use support::{share_path, CoreGuard, RecordingSink, TestNode};
|
use support::{share_path, CoreGuard, RecordingSink, TestNode};
|
||||||
use vnidrop::{CoreLimits, ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode};
|
use vnidrop::{
|
||||||
|
CoreEvent, CoreEventSink, CoreLimits, ShareMetadataInput, ShareSource, SourceKind,
|
||||||
|
TransferAccessMode,
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn share_creation_persists_selected_access_mode_atomically() {
|
fn share_creation_persists_selected_access_mode_atomically() {
|
||||||
@@ -324,55 +327,87 @@ fn source_limit_rejection_creates_no_transfer_state() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn cancellation_during_import_is_durable() {
|
fn cancellation_during_import_is_durable() {
|
||||||
let source_dir = tempfile::tempdir().unwrap();
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
let source_path = source_dir.path().join("large.bin");
|
let source_path = source_dir.path().join("gated.bin");
|
||||||
std::fs::File::create(&source_path)
|
std::fs::write(&source_path, vec![0u8; 256 * 1024]).unwrap();
|
||||||
.unwrap()
|
let data_dir = tempfile::tempdir().unwrap();
|
||||||
.set_len(256 * 1024 * 1024)
|
let import_gate = Arc::new(ImportStartedGate::default());
|
||||||
.unwrap();
|
let sender = CoreGuard::start(data_dir.path(), import_gate.clone());
|
||||||
let sender = TestNode::new();
|
let core = sender.arc();
|
||||||
let core = sender.core.arc();
|
|
||||||
let worker = std::thread::spawn(move || {
|
let worker = std::thread::spawn(move || {
|
||||||
core.share_files(
|
core.share_files(
|
||||||
vec![ShareSource {
|
vec![ShareSource {
|
||||||
kind: SourceKind::Path,
|
kind: SourceKind::Path,
|
||||||
value: source_path.to_string_lossy().to_string(),
|
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,
|
is_directory: false,
|
||||||
}],
|
}],
|
||||||
ShareMetadataInput {
|
ShareMetadataInput {
|
||||||
transfer_id: 25,
|
transfer_id: 25,
|
||||||
transfer_name: Some("large".to_string()),
|
transfer_name: Some("gated".to_string()),
|
||||||
sender_name: None,
|
sender_name: None,
|
||||||
access_mode: TransferAccessMode::ApprovalRequired,
|
access_mode: TransferAccessMode::ApprovalRequired,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
let started = Instant::now();
|
// The synchronous event callback holds the worker after active-transfer
|
||||||
loop {
|
// registration, so cancellation cannot race a fast import to completion.
|
||||||
if sender
|
import_gate.wait_until_blocked();
|
||||||
.core
|
let cancel_result = sender.cancel_transfer(25);
|
||||||
.list_transfers()
|
import_gate.release();
|
||||||
.unwrap()
|
cancel_result.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();
|
|
||||||
assert!(worker.join().unwrap().is_err());
|
assert!(worker.join().unwrap().is_err());
|
||||||
|
|
||||||
let transfer = sender
|
let transfer = sender
|
||||||
.core
|
|
||||||
.list_transfers()
|
.list_transfers()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|transfer| transfer.transfer_id == 25)
|
.find(|transfer| transfer.transfer_id == 25)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(transfer.status, "cancelled");
|
assert_eq!(transfer.status, "cancelled");
|
||||||
assert_eq!(sender.core.status().active_transfers, 0);
|
assert_eq!(sender.status().active_transfers, 0);
|
||||||
assert_eq!(sender.core.status().active_shares, 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,20 @@ plugins {
|
|||||||
alias(libs.plugins.composeCompiler)
|
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 {
|
dependencies {
|
||||||
implementation(projects.shared)
|
implementation(projects.shared)
|
||||||
|
|
||||||
@@ -20,19 +34,28 @@ dependencies {
|
|||||||
compose.desktop {
|
compose.desktop {
|
||||||
application {
|
application {
|
||||||
mainClass = "com.vnidrop.app.MainKt"
|
mainClass = "com.vnidrop.app.MainKt"
|
||||||
|
buildTypes.release.proguard.isEnabled.set(false)
|
||||||
|
|
||||||
nativeDistributions {
|
nativeDistributions {
|
||||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
targetFormats(TargetFormat.Dmg, TargetFormat.Deb, TargetFormat.Rpm)
|
||||||
packageName = "com.vnidrop.app"
|
packageName = "VniDrop"
|
||||||
packageVersion = "1.0.0"
|
packageVersion = appVersion
|
||||||
|
description = "Send files directly across your devices"
|
||||||
|
vendor = "Sudosy Labs"
|
||||||
|
licenseFile.set(project.file("../LICENSE"))
|
||||||
macOS {
|
macOS {
|
||||||
|
bundleID = "com.vnidrop.app"
|
||||||
iconFile.set(project.file("../assets/macos/app-icon.icns"))
|
iconFile.set(project.file("../assets/macos/app-icon.icns"))
|
||||||
}
|
}
|
||||||
windows {
|
windows {
|
||||||
iconFile.set(project.file("../assets/windows/app-icon.ico"))
|
iconFile.set(project.file("../assets/windows/app-icon.ico"))
|
||||||
}
|
}
|
||||||
linux {
|
linux {
|
||||||
|
packageName = "vnidrop"
|
||||||
iconFile.set(project.file("../assets/linux/app-icon.png"))
|
iconFile.set(project.file("../assets/linux/app-icon.png"))
|
||||||
|
debMaintainer = "support@sudosy.fr"
|
||||||
|
appRelease = "1"
|
||||||
|
rpmLicenseType = "Apache-2.0"
|
||||||
}
|
}
|
||||||
fileAssociation(
|
fileAssociation(
|
||||||
mimeType = "application/vnd.vnidrop.transfer",
|
mimeType = "application/vnd.vnidrop.transfer",
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ fun main(args: Array<String>) {
|
|||||||
Window(
|
Window(
|
||||||
onCloseRequest = ::exitApplication,
|
onCloseRequest = ::exitApplication,
|
||||||
state = windowState,
|
state = windowState,
|
||||||
title = "vnidrop",
|
title = "VniDrop",
|
||||||
// Compose keeps edge resizers active for this client-decorated Linux window.
|
// Compose keeps edge resizers active for this client-decorated Linux window.
|
||||||
undecorated = linux,
|
undecorated = linux,
|
||||||
) {
|
) {
|
||||||
@@ -166,7 +166,7 @@ private fun WindowScope.MacOsTitleBar() {
|
|||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(end = MacOsTrafficLightsWidth)) {
|
Box(modifier = Modifier.fillMaxSize().padding(end = MacOsTrafficLightsWidth)) {
|
||||||
BasicText(
|
BasicText(
|
||||||
text = "vnidrop",
|
text = "VniDrop",
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
color = colors.foregroundDefault,
|
color = colors.foregroundDefault,
|
||||||
@@ -195,7 +195,7 @@ private fun WindowScope.LinuxTitleBar(
|
|||||||
.background(colors.backgroundSurface200),
|
.background(colors.backgroundSurface200),
|
||||||
) {
|
) {
|
||||||
BasicText(
|
BasicText(
|
||||||
text = "vnidrop",
|
text = "VniDrop",
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
color = colors.foregroundDefault,
|
color = colors.foregroundDefault,
|
||||||
|
|||||||
10
docs/.gitignore
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 devices—direct 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 does—and doesn’t 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
@@ -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, VniDrop’s data controller is the
|
||||||
|
individual publisher named in the applicable app-store listing. In this policy,
|
||||||
|
“VniDrop,” “we,” and “us” also include the maintainers acting on that publisher’s
|
||||||
|
behalf. The publisher can be reached at support@sudosy.fr.
|
||||||
|
</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 receiver’s chosen display or device name,
|
||||||
|
application version, and a technical endpoint identifier to the sender. A direct
|
||||||
|
connection exposes the peers’ IP addresses to one another. When a public relay is
|
||||||
|
used, its operator can observe connection metadata such as source and destination IP
|
||||||
|
addresses, connection time, and the amount of relayed data, but cannot read the
|
||||||
|
encrypted transfer contents.
|
||||||
|
</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 app’s data, or uninstall the app, subject to operating-system file
|
||||||
|
behavior. Removing VniDrop history does not delete a file you already downloaded;
|
||||||
|
delete that file through your operating system if you no longer want it.
|
||||||
|
</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 information—such
|
||||||
|
as IP address, time, requested page, referrer, and browser user agent—to 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 & 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 & 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. GitHub’s 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
288
docs/app/privacy/privacy-document.css
Normal 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: "↗";
|
||||||
|
}
|
||||||
89
docs/app/privacy/privacy-responsive.css
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
docs/components/platform-icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
docs/components/reveal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
docs/components/site-footer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
docs/components/site-header.tsx
Normal 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
@@ -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
@@ -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
@@ -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
30
docs/package.json
Normal 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
|
After Width: | Height: | Size: 1.1 MiB |
26
docs/public/brand-mark.svg
Normal 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 |
16
docs/public/site.webmanifest
Normal 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
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -6,6 +6,11 @@ kotlin.mpp.enableCInteropCommonization=true
|
|||||||
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
|
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
|
||||||
org.gradle.configuration-cache=true
|
org.gradle.configuration-cache=true
|
||||||
org.gradle.caching=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
|
||||||
android.builtInKotlin=false
|
android.builtInKotlin=false
|
||||||
android.newDsl=false
|
android.newDsl=false
|
||||||
@@ -16,8 +21,9 @@ android.useAndroidX=true
|
|||||||
# VniDrop: compile-time diagnostics/telemetry product surface.
|
# VniDrop: compile-time diagnostics/telemetry product surface.
|
||||||
# false → no Share-diagnostics toggle, no telemetry or crash auto-upload stack.
|
# false → no Share-diagnostics toggle, no telemetry or crash auto-upload stack.
|
||||||
# Bug report UI remains available (user-initiated).
|
# Bug report UI remains available (user-initiated).
|
||||||
# Override per build: ./gradlew … -Pvnidrop.diagnostics.included=false
|
# Enable per build only when endpoint and ingest key are configured:
|
||||||
vnidrop.diagnostics.included=true
|
# ./gradlew … -Pvnidrop.diagnostics.included=true
|
||||||
|
vnidrop.diagnostics.included=false
|
||||||
# Cloudflare Worker base URL (no trailing slash). Both endpoint/key empty → NoOp transport.
|
# Cloudflare Worker base URL (no trailing slash). Both endpoint/key empty → NoOp transport.
|
||||||
# Example: https://vnidrop-diagnostics.<subdomain>.workers.dev
|
# Example: https://vnidrop-diagnostics.<subdomain>.workers.dev
|
||||||
vnidrop.diagnostics.endpoint=
|
vnidrop.diagnostics.endpoint=
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
TEAM_ID=
|
TEAM_ID=
|
||||||
|
|
||||||
PRODUCT_NAME=vnidrop
|
PRODUCT_NAME=VniDrop
|
||||||
PRODUCT_BUNDLE_IDENTIFIER=com.vnidrop.app.vnidrop$(TEAM_ID)
|
PRODUCT_BUNDLE_IDENTIFIER=com.vnidrop.app.vnidrop$(TEAM_ID)
|
||||||
|
|
||||||
CURRENT_PROJECT_VERSION=1
|
CURRENT_PROJECT_VERSION=1
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* 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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
@@ -47,14 +47,6 @@
|
|||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
C13A067056BA9F38FD87A539 /* Products */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
FA325F1B4E7D8FFDF19A5C4A /* vnidrop.app */,
|
|
||||||
);
|
|
||||||
name = Products;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
CBA2EB46BC3B70D303C46327 = {
|
CBA2EB46BC3B70D303C46327 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -64,6 +56,14 @@
|
|||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
C13A067056BA9F38FD87A539 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
FA325F1B4E7D8FFDF19A5C4A /* VniDrop.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
);
|
);
|
||||||
productName = iosApp;
|
productName = iosApp;
|
||||||
productReference = FA325F1B4E7D8FFDF19A5C4A /* vnidrop.app */;
|
productReference = FA325F1B4E7D8FFDF19A5C4A /* VniDrop.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
|
|||||||
84
packaging/linux/README.md
Normal 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.
|
||||||
39
packaging/linux/resolve-version.sh
Executable 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"
|
||||||
96
packaging/linux/verify-package.sh
Executable 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"
|
||||||
56
packaging/windows/AppxManifest.xml
Normal 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>
|
||||||
BIN
packaging/windows/Assets/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
packaging/windows/Assets/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
BIN
packaging/windows/Assets/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
105
packaging/windows/README.md
Normal 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)
|
||||||
280
packaging/windows/build-msix.ps1
Normal 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"
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "vnidrop-diagnostics-api",
|
"name": "vnidrop-diagnostics-api",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.12"
|
"node": ">=22.12"
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import gobley.gradle.GobleyHost
|
|||||||
import gobley.gradle.rust.targets.RustAndroidTarget
|
import gobley.gradle.rust.targets.RustAndroidTarget
|
||||||
import gobley.gradle.rust.targets.RustAppleMobileTarget
|
import gobley.gradle.rust.targets.RustAppleMobileTarget
|
||||||
import gobley.gradle.rust.targets.RustTarget
|
import gobley.gradle.rust.targets.RustTarget
|
||||||
|
import gobley.gradle.Variant
|
||||||
import org.gradle.api.DefaultTask
|
import org.gradle.api.DefaultTask
|
||||||
import org.gradle.api.provider.ListProperty
|
import org.gradle.api.provider.ListProperty
|
||||||
import org.gradle.api.tasks.Input
|
import org.gradle.api.tasks.Input
|
||||||
import org.gradle.api.tasks.PathSensitivity
|
import org.gradle.api.tasks.PathSensitivity
|
||||||
import org.gradle.api.tasks.TaskAction
|
import org.gradle.api.tasks.TaskAction
|
||||||
|
import org.gradle.jvm.tasks.Jar
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
abstract class VerifyHostCargoTaskSelection : DefaultTask() {
|
abstract class VerifyHostCargoTaskSelection : DefaultTask() {
|
||||||
@@ -37,16 +39,27 @@ plugins {
|
|||||||
alias(libs.plugins.kotlinAtomicfu)
|
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…).
|
// Compile-time switches (gradle.properties or -P…).
|
||||||
// included=false: no Share-diagnostics toggle, no telemetry/crash auto-upload stack.
|
// 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).
|
// endpoint/key both empty: transport is NoOp (safe default until Cloudflare is deployed).
|
||||||
val diagnosticsIncluded: Boolean =
|
val diagnosticsIncluded: Boolean =
|
||||||
(findProperty("vnidrop.diagnostics.included") as String?)?.toBooleanStrictOrNull() ?: true
|
(findProperty("vnidrop.diagnostics.included") as String?)?.toBooleanStrictOrNull() ?: false
|
||||||
val diagnosticsEndpoint: String =
|
val diagnosticsEndpoint: String =
|
||||||
(findProperty("vnidrop.diagnostics.endpoint") as String?)?.trim().orEmpty()
|
(findProperty("vnidrop.diagnostics.endpoint") as String?)?.trim().orEmpty()
|
||||||
val diagnosticsIngestKey: String =
|
val diagnosticsIngestKey: String =
|
||||||
(findProperty("vnidrop.diagnostics.ingestKey") as String?)?.trim().orEmpty()
|
(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"
|
"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"
|
description = "Generates DiagnosticsBuildConfig from vnidrop.diagnostics.* properties"
|
||||||
val outputDir = diagnosticsBuildConfigDir
|
val outputDir = diagnosticsBuildConfigDir
|
||||||
val included = diagnosticsIncluded
|
val included = diagnosticsIncluded
|
||||||
val endpoint = diagnosticsEndpoint
|
val endpoint = if (included) diagnosticsEndpoint else ""
|
||||||
val ingestKey = diagnosticsIngestKey
|
val ingestKey = if (included) diagnosticsIngestKey else ""
|
||||||
inputs.property("vnidrop.diagnostics.included", included)
|
inputs.property("vnidrop.diagnostics.included", included)
|
||||||
inputs.property("vnidrop.diagnostics.endpoint", endpoint)
|
inputs.property("vnidrop.diagnostics.endpoint", endpoint)
|
||||||
inputs.property("vnidrop.diagnostics.ingestKey", ingestKey)
|
inputs.property("vnidrop.diagnostics.ingestKey", ingestKey)
|
||||||
@@ -185,6 +198,7 @@ val hostCargoTargets = buildSet<RustTarget> {
|
|||||||
cargo {
|
cargo {
|
||||||
packageDirectory = layout.projectDirectory.dir("../crates/vnidrop")
|
packageDirectory = layout.projectDirectory.dir("../crates/vnidrop")
|
||||||
publishJvmArtifacts = true
|
publishJvmArtifacts = true
|
||||||
|
jvmVariant.set(desktopRustVariant)
|
||||||
androidTargetsToBuild.set(setOf(RustAndroidTarget.Arm64, RustAndroidTarget.X64))
|
androidTargetsToBuild.set(setOf(RustAndroidTarget.Arm64, RustAndroidTarget.X64))
|
||||||
builds.jvm {
|
builds.jvm {
|
||||||
variants {
|
variants {
|
||||||
@@ -219,9 +233,15 @@ cargo {
|
|||||||
uniffi {
|
uniffi {
|
||||||
generateFromLibrary {
|
generateFromLibrary {
|
||||||
namespace = "vnidrop"
|
namespace = "vnidrop"
|
||||||
|
build.set(GobleyHost.current.rustTarget)
|
||||||
|
variant.set(desktopRustVariant)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.named<Jar>("jvmJar") {
|
||||||
|
manifest.attributes["Implementation-Version"] = appVersion
|
||||||
|
}
|
||||||
|
|
||||||
val verifyHostCargoTaskSelection = tasks.register<VerifyHostCargoTaskSelection>(
|
val verifyHostCargoTaskSelection = tasks.register<VerifyHostCargoTaskSelection>(
|
||||||
"verifyHostCargoTaskSelection"
|
"verifyHostCargoTaskSelection"
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -18,13 +18,14 @@ class DiagnosticsCoordinator(
|
|||||||
val crashReporter: CrashReporter,
|
val crashReporter: CrashReporter,
|
||||||
val bugReports: BugReportService,
|
val bugReports: BugReportService,
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
|
private val included: Boolean = DiagnosticsBuildConfig.INCLUDED,
|
||||||
) {
|
) {
|
||||||
fun start() {
|
fun start() {
|
||||||
// Install id is useful for bug-report correlation even without telemetry.
|
// Install id is useful for bug-report correlation even without telemetry.
|
||||||
scope.launch {
|
scope.launch {
|
||||||
preferencesRepository.ensureDiagnosticsInstallId()
|
preferencesRepository.ensureDiagnosticsInstallId()
|
||||||
}
|
}
|
||||||
if (!DiagnosticsBuildConfig.INCLUDED) return
|
if (!included) return
|
||||||
crashReporter.startObservingPreferences()
|
crashReporter.startObservingPreferences()
|
||||||
crashReporter.installUnhandledExceptionHandler()
|
crashReporter.installUnhandledExceptionHandler()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -33,7 +34,7 @@ class DiagnosticsCoordinator(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun record(name: String, properties: Map<String, String> = emptyMap()) {
|
fun record(name: String, properties: Map<String, String> = emptyMap()) {
|
||||||
if (!DiagnosticsBuildConfig.INCLUDED) return
|
if (!included) return
|
||||||
telemetry.record(name, properties)
|
telemetry.record(name, properties)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ class DiagnosticsCoordinator(
|
|||||||
preferencesRepository: PreferencesRepository,
|
preferencesRepository: PreferencesRepository,
|
||||||
scope: CoroutineScope,
|
scope: CoroutineScope,
|
||||||
transport: DiagnosticsTransport = NoOpDiagnosticsTransport(),
|
transport: DiagnosticsTransport = NoOpDiagnosticsTransport(),
|
||||||
|
included: Boolean = DiagnosticsBuildConfig.INCLUDED,
|
||||||
): DiagnosticsCoordinator {
|
): DiagnosticsCoordinator {
|
||||||
val breadcrumbs = BreadcrumbBuffer()
|
val breadcrumbs = BreadcrumbBuffer()
|
||||||
val crashStore = createPendingCrashStore(appDataDir)
|
val crashStore = createPendingCrashStore(appDataDir)
|
||||||
@@ -53,6 +55,7 @@ class DiagnosticsCoordinator(
|
|||||||
transport = transport,
|
transport = transport,
|
||||||
breadcrumbs = breadcrumbs,
|
breadcrumbs = breadcrumbs,
|
||||||
scope = scope,
|
scope = scope,
|
||||||
|
included = included,
|
||||||
)
|
)
|
||||||
val crashReporter = CrashReporter(
|
val crashReporter = CrashReporter(
|
||||||
store = crashStore,
|
store = crashStore,
|
||||||
@@ -78,6 +81,7 @@ class DiagnosticsCoordinator(
|
|||||||
crashReporter = crashReporter,
|
crashReporter = crashReporter,
|
||||||
bugReports = bugReports,
|
bugReports = bugReports,
|
||||||
scope = scope,
|
scope = scope,
|
||||||
|
included = included,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ fun createDiagnosticsTransport(
|
|||||||
platform: String,
|
platform: String,
|
||||||
installIdProvider: suspend () -> String,
|
installIdProvider: suspend () -> String,
|
||||||
): DiagnosticsTransport = buildDiagnosticsTransport(
|
): DiagnosticsTransport = buildDiagnosticsTransport(
|
||||||
|
included = DiagnosticsBuildConfig.INCLUDED,
|
||||||
endpoint = DiagnosticsBuildConfig.ENDPOINT,
|
endpoint = DiagnosticsBuildConfig.ENDPOINT,
|
||||||
ingestKey = DiagnosticsBuildConfig.INGEST_KEY,
|
ingestKey = DiagnosticsBuildConfig.INGEST_KEY,
|
||||||
appVersion = appVersion,
|
appVersion = appVersion,
|
||||||
@@ -127,12 +128,14 @@ fun createDiagnosticsTransport(
|
|||||||
)
|
)
|
||||||
|
|
||||||
internal fun buildDiagnosticsTransport(
|
internal fun buildDiagnosticsTransport(
|
||||||
|
included: Boolean = true,
|
||||||
endpoint: String,
|
endpoint: String,
|
||||||
ingestKey: String,
|
ingestKey: String,
|
||||||
appVersion: String,
|
appVersion: String,
|
||||||
platform: String,
|
platform: String,
|
||||||
installIdProvider: suspend () -> String,
|
installIdProvider: suspend () -> String,
|
||||||
): DiagnosticsTransport {
|
): DiagnosticsTransport {
|
||||||
|
if (!included) return NoOpDiagnosticsTransport()
|
||||||
val normalizedEndpoint = endpoint.trim()
|
val normalizedEndpoint = endpoint.trim()
|
||||||
val normalizedIngestKey = ingestKey.trim()
|
val normalizedIngestKey = ingestKey.trim()
|
||||||
if (normalizedEndpoint.isEmpty() && normalizedIngestKey.isEmpty()) return NoOpDiagnosticsTransport()
|
if (normalizedEndpoint.isEmpty() && normalizedIngestKey.isEmpty()) return NoOpDiagnosticsTransport()
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class TelemetryRecorder(
|
|||||||
private val flushIntervalMillis: Long = DefaultFlushIntervalMillis,
|
private val flushIntervalMillis: Long = DefaultFlushIntervalMillis,
|
||||||
private val retryBackoffMillis: Long = DefaultRetryBackoffMillis,
|
private val retryBackoffMillis: Long = DefaultRetryBackoffMillis,
|
||||||
private val automaticRetryCount: Int = DefaultAutomaticRetryCount,
|
private val automaticRetryCount: Int = DefaultAutomaticRetryCount,
|
||||||
|
private val included: Boolean = true,
|
||||||
) {
|
) {
|
||||||
private val bufferMutex = Mutex()
|
private val bufferMutex = Mutex()
|
||||||
private val state = AtomicReference(TelemetryState())
|
private val state = AtomicReference(TelemetryState())
|
||||||
@@ -42,6 +43,7 @@ class TelemetryRecorder(
|
|||||||
require(flushIntervalMillis > 0) { "flushIntervalMillis must be positive" }
|
require(flushIntervalMillis > 0) { "flushIntervalMillis must be positive" }
|
||||||
require(retryBackoffMillis > 0) { "retryBackoffMillis must be positive" }
|
require(retryBackoffMillis > 0) { "retryBackoffMillis must be positive" }
|
||||||
require(automaticRetryCount >= 0) { "automaticRetryCount must not be negative" }
|
require(automaticRetryCount >= 0) { "automaticRetryCount must not be negative" }
|
||||||
|
if (included) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
preferencesRepository.preferences
|
preferencesRepository.preferences
|
||||||
.map { it.diagnosticsEnabled }
|
.map { it.diagnosticsEnabled }
|
||||||
@@ -55,9 +57,10 @@ class TelemetryRecorder(
|
|||||||
}
|
}
|
||||||
scope.launch { runAutomaticFlushes() }
|
scope.launch { runAutomaticFlushes() }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun record(name: String, properties: Map<String, String> = emptyMap()) {
|
fun record(name: String, properties: Map<String, String> = emptyMap()) {
|
||||||
if (!DiagnosticsBuildConfig.INCLUDED) return
|
if (!included) return
|
||||||
val sanitizedName = sanitizeDiagnosticName(name)
|
val sanitizedName = sanitizeDiagnosticName(name)
|
||||||
if (sanitizedName.isBlank()) return
|
if (sanitizedName.isBlank()) return
|
||||||
val sanitizedProperties = sanitizeDiagnosticProperties(properties)
|
val sanitizedProperties = sanitizeDiagnosticProperties(properties)
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ class SettingsViewModel(
|
|||||||
private val messages: UiMessageController,
|
private val messages: UiMessageController,
|
||||||
private val bugReports: BugReportService,
|
private val bugReports: BugReportService,
|
||||||
private val diagnostics: DiagnosticsCoordinator? = null,
|
private val diagnostics: DiagnosticsCoordinator? = null,
|
||||||
|
private val diagnosticsIncluded: Boolean = DiagnosticsBuildConfig.INCLUDED,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(
|
private val _state = MutableStateFlow(
|
||||||
SettingsState(
|
SettingsState(
|
||||||
@@ -101,18 +102,16 @@ class SettingsViewModel(
|
|||||||
val effectFlow = effects.receiveAsFlow()
|
val effectFlow = effects.receiveAsFlow()
|
||||||
private var enableNotificationsAfterSettings = false
|
private var enableNotificationsAfterSettings = false
|
||||||
private var usernamePersistJob: Job? = null
|
private var usernamePersistJob: Job? = null
|
||||||
|
private var hasLocalUsernameDraft = false
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
preferencesRepository.preferences.collect { preferences ->
|
preferencesRepository.preferences.collect { preferences ->
|
||||||
val previousFolder = _state.value.receiveFolder
|
val previousFolder = _state.value.receiveFolder
|
||||||
val receiveFolder = fileSystemService.effectiveReceiveFolder(preferences.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 ->
|
_state.update { current ->
|
||||||
current.copy(
|
current.copy(
|
||||||
username = if (editingUsername) current.username else preferences.username,
|
username = if (hasLocalUsernameDraft) current.username else preferences.username,
|
||||||
receiveFolder = receiveFolder,
|
receiveFolder = receiveFolder,
|
||||||
themeMode = preferences.themeMode,
|
themeMode = preferences.themeMode,
|
||||||
notificationsEnabled = preferences.notificationsEnabled,
|
notificationsEnabled = preferences.notificationsEnabled,
|
||||||
@@ -140,6 +139,7 @@ class SettingsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setUsername(value: String) {
|
fun setUsername(value: String) {
|
||||||
|
hasLocalUsernameDraft = true
|
||||||
_state.update { it.copy(username = value) }
|
_state.update { it.copy(username = value) }
|
||||||
usernamePersistJob?.cancel()
|
usernamePersistJob?.cancel()
|
||||||
usernamePersistJob = viewModelScope.launch {
|
usernamePersistJob = viewModelScope.launch {
|
||||||
@@ -201,7 +201,7 @@ class SettingsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setDiagnosticsEnabled(enabled: Boolean) {
|
fun setDiagnosticsEnabled(enabled: Boolean) {
|
||||||
if (!DiagnosticsBuildConfig.INCLUDED) return
|
if (!diagnosticsIncluded) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
preferencesRepository.setDiagnosticsEnabled(enabled)
|
preferencesRepository.setDiagnosticsEnabled(enabled)
|
||||||
diagnostics?.record(
|
diagnostics?.record(
|
||||||
|
|||||||
@@ -273,6 +273,19 @@ class DiagnosticsTest {
|
|||||||
assertIs<NoOpDiagnosticsTransport>(transport)
|
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
|
@Test
|
||||||
fun noOpTransportReportsUnavailableDelivery() = runTest {
|
fun noOpTransportReportsUnavailableDelivery() = runTest {
|
||||||
val result = NoOpDiagnosticsTransport().sendEvents(
|
val result = NoOpDiagnosticsTransport().sendEvents(
|
||||||
@@ -329,6 +342,27 @@ class DiagnosticsTest {
|
|||||||
assertEquals(1, breadcrumbs.snapshot().size)
|
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
|
@Test
|
||||||
fun telemetryRetainsColdStartEventsUntilConsentLoads() = runTest {
|
fun telemetryRetainsColdStartEventsUntilConsentLoads() = runTest {
|
||||||
val backing = fakePrefs(diagnosticsEnabled = true)
|
val backing = fakePrefs(diagnosticsEnabled = true)
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ class ViewModelsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun settingsUsernameKeepsSpacesWhileTypingAndPersistsAfterDebounce() = runTest {
|
fun settingsUsernameDraftIsNotOverwrittenByPersistedEcho() = runTest {
|
||||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
val preferences = preferences()
|
val preferences = preferences()
|
||||||
val viewModel = settingsViewModel(preferences)
|
val viewModel = settingsViewModel(preferences)
|
||||||
@@ -105,6 +105,10 @@ class ViewModelsTest {
|
|||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertEquals("Ada", preferences.mutablePreferences.value.username)
|
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
|
@Test
|
||||||
@@ -154,7 +158,7 @@ class ViewModelsTest {
|
|||||||
fun settingsTogglesDiagnosticsPreference() = runTest {
|
fun settingsTogglesDiagnosticsPreference() = runTest {
|
||||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
val preferences = preferences()
|
val preferences = preferences()
|
||||||
val viewModel = settingsViewModel(preferences)
|
val viewModel = settingsViewModel(preferences, diagnosticsIncluded = true)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertFalse(viewModel.state.value.diagnosticsEnabled)
|
assertFalse(viewModel.state.value.diagnosticsEnabled)
|
||||||
viewModel.setDiagnosticsEnabled(true)
|
viewModel.setDiagnosticsEnabled(true)
|
||||||
@@ -163,6 +167,20 @@ class ViewModelsTest {
|
|||||||
assertTrue(viewModel.state.value.diagnosticsEnabled)
|
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
|
@Test
|
||||||
fun settingsSubmitsBugReportAndClearsForm() = runTest {
|
fun settingsSubmitsBugReportAndClearsForm() = runTest {
|
||||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
@@ -643,6 +661,7 @@ class ViewModelsTest {
|
|||||||
notifications: FakeNotificationService = FakeNotificationService(),
|
notifications: FakeNotificationService = FakeNotificationService(),
|
||||||
transport: DiagnosticsTransport = RecordingDiagnosticsTransport(),
|
transport: DiagnosticsTransport = RecordingDiagnosticsTransport(),
|
||||||
fileSystem: FakeFileSystemService = FakeFileSystemService(folder),
|
fileSystem: FakeFileSystemService = FakeFileSystemService(folder),
|
||||||
|
diagnosticsIncluded: Boolean = false,
|
||||||
) = SettingsViewModel(
|
) = SettingsViewModel(
|
||||||
environment(),
|
environment(),
|
||||||
{ DeviceInfo("Device", "Model", "OS", "Wi-Fi", "80%") },
|
{ DeviceInfo("Device", "Model", "OS", "Wi-Fi", "80%") },
|
||||||
@@ -658,6 +677,7 @@ class ViewModelsTest {
|
|||||||
platform = "Test",
|
platform = "Test",
|
||||||
logReader = { "sample log line" },
|
logReader = { "sample log line" },
|
||||||
),
|
),
|
||||||
|
diagnosticsIncluded = diagnosticsIncluded,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun receivedTransfer(id: ULong, status: TransferStatus) = Transfer(
|
private fun receivedTransfer(id: ULong, status: TransferStatus) = Transfer(
|
||||||
|
|||||||