Compare commits

...

No commits in common. "main" and "develop" have entirely different histories.

1441 changed files with 134790 additions and 16041 deletions

View file

@ -1,43 +1,6 @@
Dockerfile /sites
.github /cli
.vscode /desktop
*.md /backend # go backend
#### gitignore below
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules node_modules
.yarn
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example
.data
# deploy template
deploy-template/*
!deploy-template/compose.yml
# generated prisma client
/prisma/client
/prisma/validate

View file

@ -1,55 +0,0 @@
name: CI
on:
push:
branches:
- develop
pull_request:
branches:
- develop
permissions:
contents: read
jobs:
typecheck:
name: Typecheck
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
with:
submodules: true
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: "yarn"
- name: Install dependencies
run: yarn install --immutable --network-timeout 1000000
- name: Typecheck
run: yarn typecheck
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
with:
submodules: true
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: "yarn"
- name: Install dependencies
run: yarn install --immutable --network-timeout 1000000
- name: Lint
run: yarn lint

140
.github/workflows/client-release.yml vendored Normal file
View file

@ -0,0 +1,140 @@
name: "Build and release desktop"
on:
workflow_dispatch:
inputs:
tagName:
required: false
type: string
description: "tagName to be associated with this release."
release:
types: [published]
# This can be used to automatically publish nightlies at UTC nighttime
# schedule:
# - cron: "0 2 * * *" # run at 2 AM UTC
# This workflow will trigger on each push to the `release` branch to create or update a GitHub release, build your app, and upload the artifacts to the release.
jobs:
publish-tauri:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
- platform: "macos-14" # for Arm based macs (M1 and above).
args: "--target aarch64-apple-darwin"
- platform: "macos-14" # for Intel based macs.
args: "--target x86_64-apple-darwin"
- platform: "ubuntu-22.04" # for Tauri v1 you could replace this with ubuntu-20.04.
args: ""
- platform: "ubuntu-22.04-arm"
args: "--target aarch64-unknown-linux-gnu"
- platform: "windows-latest"
args: ""
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: setup pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: setup node
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: pnpm
- name: install Rust nightly
uses: dtolnay/rust-toolchain@nightly
with:
# Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds.
targets: ${{ matrix.platform == 'macos-14' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: './desktop/src-tauri -> target'
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'ubuntu-22.04-arm' # This must match the platform value defined above.
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
# webkitgtk 4.0 is for Tauri v1 - webkitgtk 4.1 is for Tauri v2.
- name: Import Apple Developer Certificate
if: matrix.platform == 'macos-14'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -t 3600 -u build.keychain
# Add build.keychain to the user keychain search list so that codesign
# (invoked later by tauri-action WITHOUT an explicit --keychain) can
# resolve the signing identity from it.
security list-keychains -d user -s build.keychain $(security list-keychains -d user | tr -d '"')
echo "Created keychain"
curl https://droposs.org/drop.der --output drop.der
# swiftc libs/appletrust/add-certificate.swift
# ./add-certificate drop.der
# rm add-certificate
# echo "Added certificate to keychain using swift util"
## Script is equivalent to:
sudo security authorizationdb write com.apple.trust-settings.admin allow
sudo security add-trusted-cert -d -r trustRoot -k build.keychain -p codeSign -u -1 drop.der
sudo security authorizationdb remove com.apple.trust-settings.admin
security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
echo "Imported certificate"
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
security find-identity -v -p codesigning build.keychain
- name: Verify Certificate
if: matrix.platform == 'macos-14'
run: |
CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Drop OSS")
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV
echo "Certificate imported. Using identity: $CERT_ID"
- name: install frontend dependencies
run: pnpm install # change this to npm, pnpm or bun depending on which one you use.
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Do NOT set APPLE_CERTIFICATE / APPLE_CERTIFICATE_PASSWORD here. Doing so
# makes tauri-action import the cert into its own throwaway keychain and
# look up the identity by Apple-only name prefixes (e.g.
# "Developer ID Application:"), which never matches our "Drop OSS" cert
# and fails with "failed to resolve signing identity". Instead we rely on
# the build.keychain prepared above and only pass the resolved identity.
APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }}
NO_STRIP: true
with:
tagName: ${{ inputs.print_tags || 'v__VERSION__' }} # the action automatically replaces \_\_VERSION\_\_ with the app version.
releaseName: "Auto-release v__VERSION__"
releaseBody: "See the assets to download this version and install. This release was created automatically."
releaseDraft: false
prerelease: true
args: ${{ matrix.args }}
projectPath: './desktop'

56
.github/workflows/droplet-ci.yml vendored Normal file
View file

@ -0,0 +1,56 @@
name: Droplet CI
on:
push:
branches: [develop]
paths:
- "libraries/droplet/**"
- "libraries/droplet_types/**"
- "libraries/libarchive/**"
- ".github/workflows/droplet-ci.yml"
pull_request:
branches: [develop]
paths:
- "libraries/droplet/**"
- "libraries/droplet_types/**"
- "libraries/libarchive/**"
- ".github/workflows/droplet-ci.yml"
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
jobs:
ci:
name: Build, Test, Lint
runs-on: ubuntu-latest
defaults:
run:
working-directory: libraries/droplet
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@nightly
with:
components: rustfmt, clippy
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: "./libraries/droplet -> target"
- name: Install libarchive
run: |
sudo apt-get update
sudo apt-get install -y libarchive-dev
- name: Check formatting
run: cargo fmt --all -- --check
- name: Run Clippy (lint)
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Run tests
run: cargo test --all-features --all --verbose

100
.github/workflows/pages.yml vendored Normal file
View file

@ -0,0 +1,100 @@
name: Deploy website to GitHub Pages
on:
# Runs on pushes targeting the default branch
push:
branches: [develop]
paths:
- "sites/promo/**"
- "sites/docs/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- ".github/workflows/pages.yml"
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment per the "pages" group, skipping runs queued
# between the in-progress run and the latest queued one. cancel-in-progress defaults
# to false, so in-flight production deployments are allowed to complete.
concurrency: "pages"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
# Only install the promo site (radiant) and docs site (docs-next) and their
# dependencies so the public website deploy stays decoupled from the
# server/desktop build pipelines.
- name: Install dependencies
run: pnpm install --filter radiant... --filter docs-next...
- name: Setup Pages
id: setup_pages
uses: actions/configure-pages@v5
- name: Restore cache
uses: actions/cache@v4
with:
path: |
sites/promo/.next/cache
# Generate a new cache whenever packages or source files change.
key: ${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('sites/promo/**.[jt]s', 'sites/promo/**.[jt]sx') }}
# If source files changed but packages didn't, rebuild from a prior cache.
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-
- name: Build promo site with Next.js
working-directory: sites/promo
run: pnpm run build
env:
PAGES_BASE_PATH: ${{ steps.setup_pages.outputs.base_path }}
- name: Build docs site with Astro
working-directory: sites/docs
run: pnpm run build
# Nest the Starlight docs (built with base: "/docs") inside the promo export
# so both ship from a single GitHub Pages deployment at /docs.
- name: Assemble docs into /docs
run: |
rm -rf sites/promo/out/docs
mkdir -p sites/promo/out/docs
cp -r sites/docs/dist/. sites/promo/out/docs/
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: sites/promo/out
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

71
.github/workflows/server-ci.yml vendored Normal file
View file

@ -0,0 +1,71 @@
name: Server CI
on:
push:
branches: [develop]
paths:
- "server/**"
- "libraries/base/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- ".github/workflows/server-ci.yml"
pull_request:
branches: [develop]
paths:
- "server/**"
- "libraries/base/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- ".github/workflows/server-ci.yml"
permissions:
contents: read
jobs:
typecheck:
name: Typecheck
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Typecheck
working-directory: server
run: pnpm run typecheck
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Lint
working-directory: server
run: pnpm run lint

View file

@ -1,4 +1,4 @@
name: Release Workflow name: Build and release server
on: on:
workflow_dispatch: {} workflow_dispatch: {}
@ -8,10 +8,20 @@ on:
schedule: schedule:
- cron: "0 2 * * *" # run at 2 AM UTC - cron: "0 2 * * *" # run at 2 AM UTC
env:
REGISTRY_IMAGE: ghcr.io/drop-oss/drop
jobs: jobs:
web: build:
name: Push website Docker image to registry strategy:
runs-on: ubuntu-latest fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions: permissions:
packages: write packages: write
contents: read contents: read
@ -19,12 +29,35 @@ jobs:
- name: Check out the repo - name: Check out the repo
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
submodules: true
fetch-depth: 3 # fix for when this gets triggered by tag fetch-depth: 3 # fix for when this gets triggered by tag
fetch-tags: true fetch-tags: true
ref: ${{ github.ref }} ref: ${{ github.ref }}
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_IMAGE }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Determine final version - name: Determine final version
id: get_final_ver id: get_final_ver
run: | run: |
@ -43,22 +76,58 @@ jobs:
echo "Drop's release tag will be: $FINAL_VER" echo "Drop's release tag will be: $FINAL_VER"
echo "final_ver=$FINAL_VER" >> $GITHUB_OUTPUT echo "final_ver=$FINAL_VER" >> $GITHUB_OUTPUT
- name: Set up QEMU - name: Build and push by digest
uses: docker/setup-qemu-action@v3 id: build
uses: docker/build-push-action@v6
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
with: with:
buildkitd-flags: --debug platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ env.REGISTRY_IMAGE }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
provenance: mode=max
sbom: true
build-args: |
BUILD_DROP_VERSION=${{ steps.get_final_ver.outputs.final_ver }}
BUILD_GIT_REF=${{ github.sha }}
- name: Log in to the Container registry - name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
permissions:
packages: write
contents: read
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
@ -77,33 +146,12 @@ jobs:
# set latest tag for stable releases # set latest tag for stable releases
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }} type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }}
- name: Cache - name: Create manifest list and push
uses: actions/cache@v4 working-directory: ${{ runner.temp }}/digests
id: cache run: |
with: docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
path: cache-mount $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
key: cache-mount-${{ hashFiles('Dockerfile') }}
- name: Restore Docker cache mounts - name: Inspect image
uses: reproducible-containers/buildkit-cache-dance@v3 run: |
with: docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }}
builder: ${{ steps.setup-buildx.outputs.name }}
cache-dir: cache-mount
dockerfile: Dockerfile
skip-extraction: ${{ steps.cache.outputs.cache-hit }}
- name: Build and push image
id: build-and-push
uses: docker/build-push-action@v6
with:
context: .
push: true
provenance: mode=max
sbom: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
BUILD_DROP_VERSION=${{ steps.get_final_ver.outputs.final_ver }}

39
.gitignore vendored
View file

@ -1,37 +1,2 @@
# Nuxt dev/build outputs dist/
.output node_modules/
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
.yarn
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example
.data
# deploy template
deploy-template/*
!deploy-template/compose.yml
# generated prisma client
/prisma/client
/prisma/validate

View file

@ -1,54 +0,0 @@
variables:
GIT_SUBMODULE_STRATEGY: recursive
stages:
- build
services:
- docker:24.0.5-dind
before_script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
build:
stage: build
image: docker:latest
variables:
IMAGE_NAME: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_NAME:$CI_COMMIT_SHORT_SHA
LATEST_IMAGE_NAME: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_NAME:latest
PUBLISH_IMAGE_NAME: $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
PUBLISH_LATEST_IMAGE_NAME: $CI_REGISTRY_IMAGE:latest
script:
- docker build -t $IMAGE_NAME .
- docker image tag $IMAGE_NAME $LATEST_IMAGE_NAME
- docker push $IMAGE_NAME
- docker push $LATEST_IMAGE_NAME
- |
if [ $CI_COMMIT_TAG ]; then
docker image tag $IMAGE_NAME $PUBLISH_IMAGE_NAME
docker image tag $IMAGE_NAME $PUBLISH_LATEST_IMAGE_NAME
docker push $PUBLISH_IMAGE_NAME $PUBLISH_LATEST_IMAGE_NAME
fi
build-arm64:
stage: build
image: arm64v8/docker:latest
tags:
- aarch64
variables:
IMAGE_NAME: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_NAME:$CI_COMMIT_SHORT_SHA-arm64
LATEST_IMAGE_NAME: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_NAME:latest-arm64
PUBLISH_IMAGE_NAME: $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG-arm64
PUBLISH_LATEST_IMAGE_NAME: $CI_REGISTRY_IMAGE:latest-arm64
script:
- docker build -t $IMAGE_NAME . --platform=linux/arm64
- docker image tag $IMAGE_NAME $LATEST_IMAGE_NAME
- docker push $IMAGE_NAME
- docker push $LATEST_IMAGE_NAME
- |
if [ $CI_COMMIT_TAG ]; then
docker image tag $IMAGE_NAME $PUBLISH_IMAGE_NAME
docker image tag $IMAGE_NAME $PUBLISH_LATEST_IMAGE_NAME
docker push $PUBLISH_IMAGE_NAME
docker push $PUBLISH_LATEST_IMAGE_NAME
fi

3
.gitmodules vendored
View file

@ -1,3 +0,0 @@
[submodule "drop-base"]
path = drop-base
url = https://github.com/Drop-OSS/drop-base.git

View file

@ -1 +0,0 @@
drop-base/

View file

@ -1,271 +0,0 @@
# CONTRIBUTING GUIDELINES
Drop is a community-driven project. Contribution is welcome, encouraged, and appreciated.
It is also essential for the development of the project.
First, please take a moment to review our [code of conduct](CODE_OF_CONDUCT.md).
These guidelines are an attempt at better addressing pending
issues and pull requests. Please read them closely.
Foremost, be so kind as to [search](#use-the-search-luke). This ensures any contribution
you would make is not already covered.
<!-- TOC updateonsave:true depthfrom:2 -->
- [Reporting Issues](#reporting-issues)
- [You have a problem](#you-have-a-problem)
- [You have a suggestion](#you-have-a-suggestion)
- [Development](#development)
- [Note: `--optional` flag is **REQUIRED**](#note-optional-flag-is-required)
- [Tech Stack](#tech-stack)
- [Submitting Pull Requests](#submitting-pull-requests)
- [Getting started](#getting-started)
- [You have a solution](#you-have-a-solution)
- [You have an addition](#you-have-an-addition)
- [Use the Search, Luke](#use-the-search-luke)
- [Translation](#translation)
- [Commit Guidelines](#commit-guidelines)
- [Format](#format)
- [Style](#style)
<!-- /TOC -->
## Reporting Issues
### You have a problem
Please be so kind as to [search](#use-the-search-luke) for any open issue already covering
your problem.
If you find one, comment on it, so we know more people are experiencing it.
<!--
TODO: Add Troubleshooting
If not, look at the [Troubleshooting](https://github.com/Drop-OSS/docs/Troubleshooting)
page for instructions on how to gather data to better debug your problem.
-->
If you cannot find an existing issue, you can go ahead and create an issue with as much
detail as you can provide.
It should include the data gathered as indicated above, along with the following:
1. How to reproduce the problem
2. What the correct behavior should be
3. What the actual behavior is
Please copy to anyone relevant (e.g. plugin maintainers) by mentioning their GitHub handle
(starting with `@`) in your message.
We will do our very best to help you.
### You have a suggestion
Please be so kind as to [search](#use-the-search-luke) for any open issue already covering
your suggestion.
If you find one, comment on it, so we know more people are supporting it.
If not, you can go ahead and create an issue. Please copy to anyone relevant (e.g. plugin
maintainers) by mentioning their GitHub handle (starting with `@`) in your message.
## Development
To get started with development, you need `yarn` and `docker compose` installed (or know how to set up a PostgreSQL database).
Steps:
1. Run `git submodule update --init --recursive` to setup submodules
1. Copy the `.env.example` to `.env` and add any api keys you need to use (e.g. for the Giant Bomb API)
- You can find other configuration options in the [documentation](https://docs.droposs.org/)
1. Create the `.data` directory with `mkdir .data`
1. Ensure that your user owns the `.data` directory with `sudo chown -R $(id -u $(whoami))`
1. Open up a terminal and navigate to `dev-tools`, and run `docker compose up`
1. Open up another terminal in the root directory of the project and run `yarn` and then `yarn prisma migrate dev` to setup the database
1. Run `yarn dev` to start the development server
As part of the first-time bootstrap, Drop creates an invitation with the fixed id of 'admin'. So, to create an admin account, go to:
http://localhost:3000/auth/register?id=admin
### Tech Stack
This repo uses the Nuxt 3 + TailwindCSS stack, with the `yarn` package manager.
For the database, Drop uses Prisma connected to PostgreSQL.
## Submitting Pull Requests
### Getting started
You should be familiar with the basics of
[contributing on GitHub](https://help.github.com/articles/using-pull-requests)
<!--and have a fork
[properly set up](https://github.com/drop/docs/Contribution-Technical-Practices).
-->
You MUST always create PRs with _a dedicated branch_ based on the latest upstream tree.
If you create your own PR, please make sure you do it right. Also be so kind as to reference
any issue that would be solved in the PR description body,
[for instance](https://help.github.com/articles/closing-issues-via-commit-messages/)
_"Fixes #XXXX"_ for issue number XXXX.
### You have a solution
Please be so kind as to [search](#use-the-search-luke) for any open issue already covering
your [problem](#you-have-a-problem), and any pending/merged/rejected PR covering your solution.
If the solution is already reported, try it out and +1 the pull request if the
solution works ok. On the other hand, if you think your solution is better, post
it with reference to the other one so we can have both solutions to compare.
If not, then go ahead and submit a PR. Please copy to anyone relevant (e.g. plugin
maintainers) by mentioning their GitHub handle (starting with `@`) in your message.
### You have an addition
We are absolutely accepting more contributions or features to drop, but please, make sure
that it is reasonable. Contributions that only cover a very small niche are likely to not
be added.
Please be so kind as to [search](#use-the-search-luke) for any pending, merged or rejected Pull Requests
covering or related to what you want to add.
If you find one, try it out and work with the author on a common solution.
If not, then go ahead and submit a PR. Please copy to anyone relevant (e.g. plugin
maintainers) by mentioning their GitHub handle (starting with `@`) in your message.
For any extensive change, such as API changes, you will have to find testers to +1 your PR.
---
## Use the Search, Luke
_May the Force (of past experiences) be with you_
GitHub offers [many search features](https://help.github.com/articles/searching-github/)
to help you check whether a similar contribution to yours already exists. Please search
before making any contribution, it avoids duplicates and eases maintenance. Trust me,
that works 90% of the time.
You can also take a look at the [FAQ](https://github.com/Drop-OSS/docs/wiki/FAQ)
to be sure your contribution has not already come up.
If all fails, your thing has probably not been reported yet, so you can go ahead
and [create an issue](#reporting-issues) or [submit a PR](#submitting-pull-requests).
---
## Translation
If you want to help translate Drop, we would love to have your help! You can do so on our [weblate instance](https://translate.droposs.org/engage/drop/). Please make sure to **read** the [message format syntax](https://vue-i18n.intlify.dev/guide/essentials/syntax.html) page before starting. We use this special syntax to enable high quality translations, and failure to do so may result in your translations **causing errors** in Drop.
## Commit Guidelines
Drop uses the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
specification. The automatic changelog tool uses these to automatically generate
a changelog based on the commit messages. Here's a guide to writing a commit message
to allow this:
### Format
```
type(scope)!: subject
```
- `type`: the type of the commit is one of the following:
- `feat`: new features.
- `fix`: bug fixes.
- `docs`: documentation changes.
- `refactor`: refactor of a particular code section without introducing
new features or bug fixes.
- `style`: code style improvements.
- `perf`: performance improvements.
- `test`: changes to the test suite.
- `ci`: changes to the CI system.
- `build`: changes to the build system.
- `chore`: for other changes that don't match previous types. This doesn't appear
in the changelog.
- `scope`: section of the codebase that the commit makes changes to. If it makes changes to
many sections, or if no section in particular is modified, leave blank without the parentheses.
Examples:
- Commit that changes the `git` plugin:
```
feat(git): add alias for `git commit`
```
- Commit that changes many plugins:
```
style: fix inline declaration of arrays
```
For changes to plugins or themes, the scope should be the plugin or theme name:
- ✅ `fix(agnoster): commit subject`
- ❌ `fix(theme/agnoster): commit subject`
- `!`: this goes after the `scope` (or the `type` if scope is empty), to indicate that the commit
introduces breaking changes.
Optionally, you can specify a message that the changelog tool will display to the user to indicate
what's changed and what they can do to deal with it. You can use multiple lines to type this message;
the changelog parser will keep reading until the end of the commit message or until it finds an empty
line.
Example (made up):
```
style(agnoster)!: change dirty git repo glyph
BREAKING CHANGE: the glyph to indicate when a git repository is dirty has
changed from a Powerline character to a standard UTF-8 emoji. You can
change it back by setting `ZSH_THEME_DIRTY_GLYPH`.
Fixes #420
Co-authored-by: Username <email>
```
- `subject`: a brief description of the changes. This will be displayed in the changelog. If you need
to specify other details, you can use the commit body, but it won't be visible.
Formatting tricks: the commit subject may contain:
- Links to related issues or PRs by writing `#issue`. This will be highlighted by the changelog tool:
```
feat(archlinux): add support for aura AUR helper (#9467)
```
- Formatted inline code by using backticks: the text between backticks will also be highlighted by
the changelog tool:
```
feat(shell-proxy): enable unexported `DEFAULT_PROXY` setting (#9774)
```
### Style
Try to keep the first commit line short. It's harder to do using this commit style but try to be
concise, and if you need more space, you can use the commit body. Try to make sure that the commit
subject is clear and precise enough that users will know what changed by just looking at the changelog.
---
<!--
## Volunteer
Very nice!! :)
Please have a look at the [Volunteer](https://github.com/ohmyzsh/ohmyzsh/wiki/Volunteers)
page for instructions on where to start and more.
-->
## Reference
This contributing guide is adapted from the
[oh-my-zsh contribution guide](https://github.com/ohmyzsh/ohmyzsh/blob/master/CONTRIBUTING.md).
If there are any issues with this, please email admin@deepcore.dev.

View file

@ -1,56 +1,160 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
### Unified deps builder # Pinned to bookworm so the glibc here matches the torrential build stage
# FROM node:lts-alpine AS deps # and the libarchive runtime package is named `libarchive13` (trixie renames it to libarchive13t64).
# WORKDIR /app FROM node:lts-bookworm-slim AS base
# COPY package.json yarn.lock ./ ENV PNPM_HOME="/pnpm"
# RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --network-timeout 1000000 --ignore-scripts ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
### Build for app
FROM node:lts-alpine AS build-system
# setup workdir - has to be the same filepath as app because fuckin' Prisma
WORKDIR /app WORKDIR /app
## so corepack knows pnpm's version
COPY . .
## prevent prompt to download
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
## setup for offline
RUN corepack pack
## don't call out to network anymore
ENV COREPACK_ENABLE_NETWORK=0
### INSTALL DEPS ONCE
FROM base AS deps
RUN pnpm install --frozen-lockfile --ignore-scripts
### BUILD TORRENTIAL
# Bookworm-pinned to match the runtime image's glibc (a trixie build would not run on bookworm).
FROM rustlang/rust:nightly-bookworm-slim AS torrential-build
## libarchive-dev + pkg-config let libarchive3-sys link libarchive dynamically (glibc).
## protobuf-compiler is kept for parity (torrential's build.rs uses a vendored protoc).
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config \
libarchive-dev \
protobuf-compiler \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY . .
## Network resilience on hosts with poor/bursty throughput to crates.io:
## observed on .88 (~11KB/s, transfers occasionally stalling near-zero for
## tens of seconds) -- cargo's libcurl transport aborts a download once it
## sees less than CARGO_HTTP_LOW_SPEED_LIMIT bytes/sec for
## CARGO_HTTP_LOW_SPEED_TIMEOUT seconds (curl default: 10 bytes/s over 30s),
## which a merely-slow-but-alive connection can trip. A from-scratch build
## on such a host burned ~50 minutes downloading crates before one transfer
## exhausted CARGO_NET_RETRY's default 3 attempts and hard-failed the whole
## build (`failed to download from .../libc/0.2.178/download`, exit 101).
## Lower the low-speed floor so a trickling-but-live transfer isn't treated
## as dead, raise the retry budget, and give sparse-index/registry
## operations a longer ceiling before giving up outright.
ENV CARGO_HTTP_LOW_SPEED_LIMIT=1
ENV CARGO_HTTP_LOW_SPEED_TIMEOUT=120
ENV CARGO_NET_RETRY=10
ENV CARGO_HTTP_TIMEOUT=600
ENV CARGO_HTTP_MULTIPLEXING=false
## Resource-pressure cap: bound cargo's parallel job count on a
## memory-contended host. Empty (default) leaves CARGO_BUILD_JOBS unset, so
## cargo falls back to its own CPU-count autodetect -- pass
## --build-arg CARGO_JOBS=2 to constrain it. Rust codegen units are
## memory-hungry per job running in parallel; this is the Rust-side twin of
## SKIP_TYPECHECK below.
ARG CARGO_JOBS=""
## Persistent cache mounts for the registry index/downloads and the
## target/ build dir: any change ANYWHERE in the repo invalidates the
## `COPY . .` above (and therefore this RUN layer) on every build, so
## without these every single build re-downloads and recompiles every
## crate from zero -- the ~50-minute cost that motivated the network
## hardening above, paid again on every future commit. Cache mounts
## live in the buildx cache store, not the image layer, so they survive
## across builds regardless of layer invalidation. sharing=locked
## because this builder can run more than one image build at a time.
## Cache mounts are NOT part of the final layer filesystem, so the
## compiled binary is copied out to a stable (non-cached) path before
## the mount unwinds -- see the run-system stage's COPY --from below,
## which reads /build/torrential-bin, not target/release/torrential
## directly.
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,target=/build/torrential/target,sharing=locked \
if [ -n "$CARGO_JOBS" ]; then export CARGO_BUILD_JOBS="$CARGO_JOBS"; fi; \
cargo build --release --manifest-path ./torrential/Cargo.toml && \
cp ./torrential/target/release/torrential /build/torrential-bin
### BUILD APP
FROM base AS build-system
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NUXT_TELEMETRY_DISABLED=1 ENV NUXT_TELEMETRY_DISABLED=1
# ENV YARN_CACHE_FOLDER=/root/.yarn
# add git so drop can determine its git ref at build ## add git so drop can determine its git ref at build
# pnpm for build RUN apt-get update && apt-get install -y --no-install-recommends git \
RUN apk add --no-cache git pnpm && rm -rf /var/lib/apt/lists/*
# copy deps and rest of project files ## copy deps and rest of project files
# COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
COPY --from=deps /app/node_modules ./node_modules
ARG BUILD_DROP_VERSION ARG BUILD_DROP_VERSION
ARG BUILD_GIT_REF ARG BUILD_GIT_REF
# build ## Resource-pressure escape hatch (see nuxt.config.ts's typescript.typeCheck
RUN pnpm import ## comment): --build-arg SKIP_TYPECHECK=true skips vue-tsc's full-project
RUN pnpm install --shamefully-hoist ## typecheck, the single most memory-hungry step of `nuxt build`, for
RUN pnpm run build ## building on a host that's already near its memory limit. Off by default.
# RUN --mount=type=cache,target=/root/.yarn yarn postinstall && yarn build ARG SKIP_TYPECHECK=false
ENV NUXT_BUILD_SKIP_TYPECHECK=$SKIP_TYPECHECK
### create run environment for Drop ## Resource-pressure cap: bound Node's old-space heap for the
FROM node:lts-alpine AS run-system ## postinstall/build step (Vite/Rolldown/vue-tsc are all Node processes).
WORKDIR /app ## Empty (default) leaves NODE_OPTIONS unset, i.e. Node's own default heap
## sizing -- pass --build-arg NODE_MAX_OLD_SPACE=4096 to constrain it.
ARG NODE_MAX_OLD_SPACE=""
ENV NODE_OPTIONS=""
RUN if [ -n "$NODE_MAX_OLD_SPACE" ]; then export NODE_OPTIONS="--max-old-space-size=$NODE_MAX_OLD_SPACE"; fi; \
pnpm run --filter=drop postinstall && pnpm run --filter=drop build
# create run environment for Drop
FROM base AS run-system
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NUXT_TELEMETRY_DISABLED=1 ENV NUXT_TELEMETRY_DISABLED=1
# The base stage's `COPY . .` puts the whole repo into the runtime WORKDIR (/app),
# but at runtime only the artifacts copied explicitly below are needed. Drop the
# inherited `torrential` source dir: the service resolves the binary by scanning
# the cwd for `torrential`, and a directory there is spawned as ./torrential and
# fails with EACCES. With it gone, resolution falls through to the `torrential`
# binary installed on PATH (/usr/bin/torrential) below.
RUN rm -rf /app/torrential
# RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn add --network-timeout 1000000 --no-lockfile --ignore-scripts prisma@6.11.1 # RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn add --network-timeout 1000000 --no-lockfile --ignore-scripts prisma@6.11.1
RUN apk add --no-cache pnpm ## runtime deps:
RUN pnpm install prisma@6.11.1 ## - libarchive13: torrential now links libarchive dynamically (glibc build)
## - p7zip-full: provides the 7z CLI
## - nginx: front-end proxy
## - openssl + ca-certificates: required by Prisma's query engine on Debian
## pnpm itself is provided by corepack (enabled in the base stage)
RUN apt-get update && apt-get install -y --no-install-recommends \
libarchive13 \
p7zip-full \
nginx \
openssl \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN pnpm install prisma@7.7.0 --global
# init prisma to download all required files # init prisma to download all required files
RUN pnpm prisma init RUN pnpm prisma init
COPY --from=build-system /app/package.json ./ COPY --from=build-system /app/server/prisma.config.ts ./
COPY --from=build-system /app/.output ./app COPY --from=build-system /app/server/.output ./app
COPY --from=build-system /app/prisma ./prisma COPY --from=build-system /app/server/prisma ./prisma
COPY --from=build-system /app/build ./startup COPY --from=build-system /app/server/build ./startup
COPY --from=build-system /app/server/build/nginx.conf /nginx.conf
COPY --from=torrential-build /build/torrential-bin /usr/bin/torrential
ENV LIBRARY="/library" ENV LIBRARY="/library"
ENV DATA="/data" ENV DATA="/data"
ENV NGINX_CONFIG="/nginx.conf"
# Nuxt's port
ENV PORT=4000
CMD ["sh", "/app/startup/launch.sh"] CMD ["sh", "/app/startup/launch.sh"]

View file

@ -6,7 +6,7 @@
# Drop # Drop
[![Website](https://img.shields.io/badge/website-000000?style=for-the-badge&logo=About.me&logoColor=white)](https://droposs.org) [![Website](https://img.shields.io/badge/website-000000?style=for-the-badge&logo=About.me&logoColor=white)](https://droposs.org)
[![Docs](https://img.shields.io/badge/DOCS-black?style=for-the-badge&logo=docusaurus)](https://docs.droposs.org/) [![Docs](https://img.shields.io/badge/DOCS-black?style=for-the-badge&logo=docusaurus)](https://droposs.org/docs)
[![Static Badge](https://img.shields.io/badge/FORUM-blue?style=for-the-badge)](https://forum.droposs.org) [![Static Badge](https://img.shields.io/badge/FORUM-blue?style=for-the-badge)](https://forum.droposs.org)
[![GitHub License](https://img.shields.io/badge/AGPL--3.0-red?style=for-the-badge)](LICENSE) [![GitHub License](https://img.shields.io/badge/AGPL--3.0-red?style=for-the-badge)](LICENSE)
[![Discord](https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/ACq4qZp4a9) [![Discord](https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/ACq4qZp4a9)
@ -28,7 +28,7 @@ Drop is an open-source game distribution platform, similar to GameVault or Steam
## Deployment ## Deployment
See our documentation on how to [deploy Drop](https://docs.droposs.org/docs/guides/quickstart) for more information. See our documentation on how to [deploy Drop](https://droposs.org/docs/admin/quickstart) for more information.
## Contributing ## Contributing

View file

@ -2,4 +2,4 @@
To report a vulnerability, please DO NOT create an issue for it To report a vulnerability, please DO NOT create an issue for it
as this may lead to the vulnerability being exploited before it as this may lead to the vulnerability being exploited before it
can be fixed. Instead, please email [security@deepcore.dev](mailto:security@deepcore.dev) can be fixed. Instead, please email [security@droposs.org](mailto:security@droposs.org)

1
backend/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/bin

19
backend/core/database.go Normal file
View file

@ -0,0 +1,19 @@
package core
import (
"context"
"fmt"
"os"
"github.com/jackc/pgx/v5"
)
func connect() {
conn, err := pgx.Connect(context.Background(), os.Getenv("DATABASE_URL"))
if err != nil {
fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err)
os.Exit(1)
}
defer conn.Close(context.Background())
}

11
backend/core/go.mod Normal file
View file

@ -0,0 +1,11 @@
module drop/core
go 1.26.1
require github.com/jackc/pgx/v5 v5.9.2
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
golang.org/x/text v0.29.0 // indirect
)

26
backend/core/go.sum Normal file
View file

@ -0,0 +1,26 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

5
backend/go.mod Normal file
View file

@ -0,0 +1,5 @@
module drop
go 1.26.1
require github.com/gorilla/mux v1.8.1

2
backend/go.sum Normal file
View file

@ -0,0 +1,2 @@
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=

3
backend/go.work Normal file
View file

@ -0,0 +1,3 @@
go 1.26.1
use ./core

9
backend/go.work.sum Normal file
View file

@ -0,0 +1,9 @@
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

37
backend/main.go Normal file
View file

@ -0,0 +1,37 @@
package main
import (
"fmt"
"log"
"net/http"
"strings"
"github.com/gorilla/mux"
)
func handler(res http.ResponseWriter, req *http.Request) {
fmt.Fprintf(res, "G'day there mate")
}
func routingMiddleware(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
url := *r.URL
url.Path = strings.TrimSuffix(r.URL.Path, "/")
r.URL = &url
h.ServeHTTP(w, r)
})
}
func main() {
r := mux.NewRouter().StrictSlash(true)
r.Use(routingMiddleware)
r.HandleFunc("/api/v1", handler)
srv := &http.Server{
Addr: ":3433",
Handler: r,
}
log.Printf("starting drop server on :3433")
srv.ListenAndServe()
}

View file

@ -1,544 +0,0 @@
## Release 0.2.0-beta
### Fixes
- fix recursive dirs util #02d6346
- Fix username length requirement #0a5a649
- remove dynamic imports #0f10626
- fix for missing developers or publishers #25fc957
- split prisma schemas #2859005
- results are returned alphabetically #33d3770
- update prisma schemas #36776cc
- removed global flag #43e32b4
- properly disconnect websockets from task handler #5358f1f
- follow best practices #54c5d55
- future lenience #5c78b20
- fix width of token breaking things #61d88c3
- fixed websocket authentication #62ea9a1
- fix delta manifest generation #6df560c
- admin invitation w/ system user #8463e35
- properly import icons #8945196
- prisma create footprint #952ece8
- game panel now always shows 3 lines exactly #9c2249e
- remove unnecessary import #a361c38
- fix disconnect code #a8f2106
- fix types #b511b40
- add drop-base as git submodule #b75ebd1
- Update README.md with discord link #c6bb21d
- fix expires requirement in the admin endpoint #c7b675f
- fix always being created as admin #c7eb11a
- moved icons and created PlatformClient so we can use the enum on the frontend #cada630
- recurse submodules #db103de
- fix FATAL: "root"... message #dbb315a
- only show versions that are directories #ef8f3ae
### Features
- update prisma & delete games #089c3e0
- manual handshake #12e3125
- fetch game endpoint #1f4d075
- under the hood organisation and consolidation #26a31f6
- 'no images' slide on image carousel #28baabc
- improve feedback when metadata fails #2c19e13
- introduction of 'system user' #2c21a23
- change name, description and icon #2cfe75a
- 'manual' metadata provider #2f52a16
- add disabled state #38fc6b8
- overhauled version importing #39d7ce7
- automatically create library folder if it doesn't exist #39fe9d5
- smoother bar in admin task ui #4488ae2
- add noWrapper option #4f9b949
- add version metadata route #5393db3
- completed admin UI, with minor changes to backend #599da0e
- adjust gradient #5a1f841
- keep track of last connected #69e4c25
- added notification system w/ interwoven refactoring #6e6f09d
- content length header for chunk downloads #76bceb1
- add title to tab #7b0756c
- add button to open in admin panel #7b3b919
- client capability framework + peer API configuration #7d72a86
- customisable image carousel and new layout #937954f
- support more types #9b12d45
- generate a server certificate for mtls APIs #9c4b6f3
- new endpoints, ui and beginnings of main store page #9cbdcbc
- backend #a309651
- more subtle design improvements #a815542
- add aden's carousel pagination design #a86045c
- add header #a8a152e
- client side search #b50e27f
- new ws handler #bc0c47c
- user widget now redirects to actual page #bfafe02
- require lowercase usernames #d7160ab
- more ui improvements #e408ac5
- add modifying game descriptions #e505e58
- mobile nav #e5cf13f
- slightly improved game page #e796b46
- game carousel #ecc819e
- add enum dictionary type #f2e0182
- improved ux #f3ed0f6
- cleanup and raw accessors #f7d767d
- add support for overriding UMU id #fd4a7d1
- add .sh for linux #fe9373a
### Other Changes
- quexeky <git@quexeky.dev>
- fixed manifest generation #03a37f7
- manual ci/cd #03b0b0c
- ability to fetch client certs for p2p #0a715fe
- disable tls in build #0f80fcd
- Updated README.md #17971e0
- Merge pull request #18 from Drop-OSS/develop
- initial work on metadata system #196f87c
- more ui #1bd19ad
- remove log statements #1d5e1bd
- small fixes & SSR disabled #1f575b2
- update information and setup guide #2236622
- metadata engine #22ac7f6
- Update CONTRIBUTING.md #2309407
- slight bug fixes and clean up #24a0d11
- almst complete admin ui and initial store designs #27070b6
- handshakes #2b4382d
- user mobile header #2e44ef3
- more consistent naming for globals #305de9f
- replaced markdown-it with micromark #31e8359
- fixes to store page for mobile clients #328b9ba
- game version re-ordering #329c74d
- verbose yarn install #36568c3
- patch for no version check in manifest generation #395219d
- migrate bcrypt to bcryptjs #3a51c9c
- added download chunk endpoint #3dd6062
- Update README.md #425934d
- build only ci #4273a20
- object storage + full permission system + testing #435551c
- rename admin socket session map #44c6028
- bump droplet and add vue carousel #46551f9
- version importing #46c8f0c
- back to yarn, with nuxt telemetry force disabled #46d35ad
- finished object endpoints #486bce8
- update dependencies and add note about optional dependencies #4fa771a
- use configuration from docs for ci/cd #52315d0
- slight fixes to register logic #583301f
- removed yarn.lock #584bcf1
- Version bump #5f29c28
- immutable application settings framework #5fe2036
- fixed docker daemon location #62a111b
- copy autodevops configuration #6328c24
- Delete .gitlab-ci.yml #69f341b
- admin ui shell #6b5e48d
- bump @drop/droplet version for windows developers #6ba5cdd
- Add LICENSE #6e2dc89
- custom dind #716eac7
- task API #718f5ba
- use gitlab ci variable declaration #7194d35
- move icons into dedicated folder #74fa671
- another stage of client authentication #7523e53
- refactoring #7869043
- moved windows logo into logos dir #789d3ba
- updated text colours across app #7a88f4c
- starting docs infra #7d2a1c6
- more cleaning #7e17626
- slight patch to rename query to be more consistent #7f4db0c
- move to raw docker #803752e
- server side and user client side completed for registration #848a611
- beginnings of download implementation #8674ac7
- more consistent naming for object handler #87230fb
- use autodevops build stage #886beb6
- Updated tailwind config #88c95d6
- change name of store file #8999303
- split prisma schemas #9011cf5
- client initiate #909432a
- more client routes to support Drop app update #91b7e10
- additional polish and QoL features #93bc143
- upload images to games #9b7ee4e
- migrate to pnpm due to ci/cd issues with yarn #9cb2d6d
- run yarn install in CI/CD non interactively #a208fbe
- completed game importing; partial work on version importing #a7c33e7
- remove canvas from dependencies #a8f58eb
- fix registry authentication #ad25d3e
- consolidate type utils #adb4b73
- Updated README.md #b0ef675
- add proper carousel to store page #b2ab827
- move to yarn v2 #b744671
- remove client API deadweight #b9ae26c
- add expires field #be6c30d
- ca groundwork #bfafd2a
- cleanup & polish #c355f6f
- remove bcrypt (debug) #c3914cc
- non rounded bottom #c4391d3
- failed gracefully on invalid chunk index #c4a3e4e
- update deploy template #c4a419f
- migrate to new droplet ca system #c4d8113
- docker based deployment #c5d00b4
- updated CONTRIBUTING.md #cd0d2bf
- update prisma version #ce0a9ab
- README update #ceacd84
- patch metadata handler #cf578bd
- Added SECURITY.md #d3d93b0
- finalised client APIs and authentication method #d4e2dc8
- Update README.md #db916bf
- object storage interface + utility functions #de388a9
- initial commit #e1a789f
- fixed task system #e1c1d7e
- Update file chunk.get.ts #e4339c3
- ui groundwork #e52f072
- Update changelog #eadcaa1
- check for no version in manifest generation #eb3f9f9
- break into single column store on lg devices #ecb381e
- better server side signin redirects #ef13b68
- patch signin #f3672f8
_changelog generated by_ [go-conventional-commits](https://github.com/joselitofilho/go-conventional-commits)
## Release 0.2.0-beta
### Fixes
- fix recursive dirs util #02d6346
- Fix username length requirement #0a5a649
- remove dynamic imports #0f10626
- fix for missing developers or publishers #25fc957
- split prisma schemas #2859005
- results are returned alphabetically #33d3770
- update prisma schemas #36776cc
- removed global flag #43e32b4
- properly disconnect websockets from task handler #5358f1f
- follow best practices #54c5d55
- future lenience #5c78b20
- fix width of token breaking things #61d88c3
- fixed websocket authentication #62ea9a1
- fix delta manifest generation #6df560c
- admin invitation w/ system user #8463e35
- properly import icons #8945196
- prisma create footprint #952ece8
- game panel now always shows 3 lines exactly #9c2249e
- remove unnecessary import #a361c38
- fix disconnect code #a8f2106
- fix types #b511b40
- add drop-base as git submodule #b75ebd1
- Update README.md with discord link #c6bb21d
- fix expires requirement in the admin endpoint #c7b675f
- fix always being created as admin #c7eb11a
- moved icons and created PlatformClient so we can use the enum on the frontend #cada630
- recurse submodules #db103de
- fix FATAL: "root"... message #dbb315a
- only show versions that are directories #ef8f3ae
### Features
- update prisma & delete games #089c3e0
- manual handshake #12e3125
- fetch game endpoint #1f4d075
- under the hood organisation and consolidation #26a31f6
- 'no images' slide on image carousel #28baabc
- improve feedback when metadata fails #2c19e13
- introduction of 'system user' #2c21a23
- change name, description and icon #2cfe75a
- 'manual' metadata provider #2f52a16
- add disabled state #38fc6b8
- overhauled version importing #39d7ce7
- automatically create library folder if it doesn't exist #39fe9d5
- smoother bar in admin task ui #4488ae2
- add noWrapper option #4f9b949
- add version metadata route #5393db3
- completed admin UI, with minor changes to backend #599da0e
- adjust gradient #5a1f841
- keep track of last connected #69e4c25
- added notification system w/ interwoven refactoring #6e6f09d
- content length header for chunk downloads #76bceb1
- add title to tab #7b0756c
- add button to open in admin panel #7b3b919
- client capability framework + peer API configuration #7d72a86
- customisable image carousel and new layout #937954f
- support more types #9b12d45
- generate a server certificate for mtls APIs #9c4b6f3
- new endpoints, ui and beginnings of main store page #9cbdcbc
- backend #a309651
- more subtle design improvements #a815542
- add aden's carousel pagination design #a86045c
- add header #a8a152e
- client side search #b50e27f
- new ws handler #bc0c47c
- user widget now redirects to actual page #bfafe02
- require lowercase usernames #d7160ab
- more ui improvements #e408ac5
- add modifying game descriptions #e505e58
- mobile nav #e5cf13f
- slightly improved game page #e796b46
- game carousel #ecc819e
- add enum dictionary type #f2e0182
- improved ux #f3ed0f6
- cleanup and raw accessors #f7d767d
- add support for overriding UMU id #fd4a7d1
- add .sh for linux #fe9373a
### Other Changes
- quexeky <git@quexeky.dev>
- fixed manifest generation #03a37f7
- manual ci/cd #03b0b0c
- ability to fetch client certs for p2p #0a715fe
- disable tls in build #0f80fcd
- Updated README.md #17971e0
- Merge pull request #18 from Drop-OSS/develop
- initial work on metadata system #196f87c
- more ui #1bd19ad
- remove log statements #1d5e1bd
- small fixes & SSR disabled #1f575b2
- update information and setup guide #2236622
- metadata engine #22ac7f6
- Update CONTRIBUTING.md #2309407
- slight bug fixes and clean up #24a0d11
- almst complete admin ui and initial store designs #27070b6
- handshakes #2b4382d
- user mobile header #2e44ef3
- more consistent naming for globals #305de9f
- replaced markdown-it with micromark #31e8359
- fixes to store page for mobile clients #328b9ba
- game version re-ordering #329c74d
- verbose yarn install #36568c3
- patch for no version check in manifest generation #395219d
- migrate bcrypt to bcryptjs #3a51c9c
- added download chunk endpoint #3dd6062
- Update README.md #425934d
- build only ci #4273a20
- object storage + full permission system + testing #435551c
- rename admin socket session map #44c6028
- bump droplet and add vue carousel #46551f9
- version importing #46c8f0c
- back to yarn, with nuxt telemetry force disabled #46d35ad
- finished object endpoints #486bce8
- update dependencies and add note about optional dependencies #4fa771a
- use configuration from docs for ci/cd #52315d0
- slight fixes to register logic #583301f
- removed yarn.lock #584bcf1
- Version bump #5f29c28
- immutable application settings framework #5fe2036
- fixed docker daemon location #62a111b
- copy autodevops configuration #6328c24
- Delete .gitlab-ci.yml #69f341b
- admin ui shell #6b5e48d
- bump @drop/droplet version for windows developers #6ba5cdd
- Add LICENSE #6e2dc89
- custom dind #716eac7
- task API #718f5ba
- use gitlab ci variable declaration #7194d35
- move icons into dedicated folder #74fa671
- another stage of client authentication #7523e53
- refactoring #7869043
- moved windows logo into logos dir #789d3ba
- updated text colours across app #7a88f4c
- starting docs infra #7d2a1c6
- more cleaning #7e17626
- slight patch to rename query to be more consistent #7f4db0c
- move to raw docker #803752e
- server side and user client side completed for registration #848a611
- beginnings of download implementation #8674ac7
- more consistent naming for object handler #87230fb
- use autodevops build stage #886beb6
- Updated tailwind config #88c95d6
- change name of store file #8999303
- split prisma schemas #9011cf5
- client initiate #909432a
- more client routes to support Drop app update #91b7e10
- additional polish and QoL features #93bc143
- upload images to games #9b7ee4e
- migrate to pnpm due to ci/cd issues with yarn #9cb2d6d
- run yarn install in CI/CD non interactively #a208fbe
- completed game importing; partial work on version importing #a7c33e7
- remove canvas from dependencies #a8f58eb
- fix registry authentication #ad25d3e
- consolidate type utils #adb4b73
- Updated README.md #b0ef675
- add proper carousel to store page #b2ab827
- move to yarn v2 #b744671
- remove client API deadweight #b9ae26c
- add expires field #be6c30d
- ca groundwork #bfafd2a
- cleanup & polish #c355f6f
- remove bcrypt (debug) #c3914cc
- non rounded bottom #c4391d3
- failed gracefully on invalid chunk index #c4a3e4e
- update deploy template #c4a419f
- migrate to new droplet ca system #c4d8113
- docker based deployment #c5d00b4
- updated CONTRIBUTING.md #cd0d2bf
- update prisma version #ce0a9ab
- README update #ceacd84
- patch metadata handler #cf578bd
- Added SECURITY.md #d3d93b0
- finalised client APIs and authentication method #d4e2dc8
- Update README.md #db916bf
- object storage interface + utility functions #de388a9
- initial commit #e1a789f
- fixed task system #e1c1d7e
- Update file chunk.get.ts #e4339c3
- ui groundwork #e52f072
- Update changelog #eadcaa1
- check for no version in manifest generation #eb3f9f9
- break into single column store on lg devices #ecb381e
- better server side signin redirects #ef13b68
- patch signin #f3672f8
_changelog generated by_ [go-conventional-commits](https://github.com/joselitofilho/go-conventional-commits)
## Release 0.1.0-beta
### Fixes
- remove dynamic imports #0f10626
- fix for missing developers or publishers #25fc957
- split prisma schemas #2859005
- results are returned alphabetically #33d3770
- properly disconnect websockets from task handler #5358f1f
- follow best practices #54c5d55
- future lenience #5c78b20
- fixed websocket authentication #62ea9a1
- fix delta manifest generation #6df560c
- admin invitation w/ system user #8463e35
- properly import icons #8945196
- prisma create footprint #952ece8
- game panel now always shows 3 lines exactly #9c2249e
- remove unnecessary import #a361c38
- fix types #b511b40
- fix expires requirement in the admin endpoint #c7b675f
- moved icons and created PlatformClient so we can use the enum on the frontend #cada630
- only show versions that are directories #ef8f3ae
### Features
- update prisma & delete games #089c3e0
- fetch game endpoint #1f4d075
- under the hood organisation and consolidation #26a31f6
- introduction of 'system user' #2c21a23
- automatically create library folder if it doesn't exist #39fe9d5
- smoother bar in admin task ui #4488ae2
- add version metadata route #5393db3
- completed admin UI, with minor changes to backend #599da0e
- keep track of last connected #69e4c25
- added notification system w/ interwoven refactoring #6e6f09d
- content length header for chunk downloads #76bceb1
- add title to tab #7b0756c
- add button to open in admin panel #7b3b919
- client capability framework + peer API configuration #7d72a86
- generate a server certificate for mtls APIs #9c4b6f3
- new endpoints, ui and beginnings of main store page #9cbdcbc
- more subtle design improvements #a815542
- add header #a8a152e
- client side search #b50e27f
- new ws handler #bc0c47c
- user widget now redirects to actual page #bfafe02
- require lowercase usernames #d7160ab
- more ui improvements #e408ac5
- slightly improved game page #e796b46
- game carousel #ecc819e
- add enum dictionary type #f2e0182
- cleanup and raw accessors #f7d767d
- add support for overriding UMU id #fd4a7d1
### Other Changes
- quexeky <git@quexeky.dev>
- fixed manifest generation #03a37f7
- manual ci/cd #03b0b0c
- ability to fetch client certs for p2p #0a715fe
- disable tls in build #0f80fcd
- Updated README.md #17971e0
- initial work on metadata system #196f87c
- more ui #1bd19ad
- remove log statements #1d5e1bd
- small fixes & SSR disabled #1f575b2
- update information and setup guide #2236622
- metadata engine #22ac7f6
- Update CONTRIBUTING.md #2309407
- slight bug fixes and clean up #24a0d11
- almst complete admin ui and initial store designs #27070b6
- handshakes #2b4382d
- user mobile header #2e44ef3
- more consistent naming for globals #305de9f
- replaced markdown-it with micromark #31e8359
- fixes to store page for mobile clients #328b9ba
- game version re-ordering #329c74d
- verbose yarn install #36568c3
- patch for no version check in manifest generation #395219d
- migrate bcrypt to bcryptjs #3a51c9c
- added download chunk endpoint #3dd6062
- Update README.md #425934d
- build only ci #4273a20
- object storage + full permission system + testing #435551c
- rename admin socket session map #44c6028
- bump droplet and add vue carousel #46551f9
- version importing #46c8f0c
- back to yarn, with nuxt telemetry force disabled #46d35ad
- finished object endpoints #486bce8
- update dependencies and add note about optional dependencies #4fa771a
- use configuration from docs for ci/cd #52315d0
- slight fixes to register logic #583301f
- removed yarn.lock #584bcf1
- Version bump #5f29c28
- immutable application settings framework #5fe2036
- fixed docker daemon location #62a111b
- copy autodevops configuration #6328c24
- Delete .gitlab-ci.yml #69f341b
- admin ui shell #6b5e48d
- bump @drop/droplet version for windows developers #6ba5cdd
- Add LICENSE #6e2dc89
- task API #718f5ba
- use gitlab ci variable declaration #7194d35
- move icons into dedicated folder #74fa671
- another stage of client authentication #7523e53
- refactoring #7869043
- moved windows logo into logos dir #789d3ba
- updated text colours across app #7a88f4c
- starting docs infra #7d2a1c6
- more cleaning #7e17626
- slight patch to rename query to be more consistent #7f4db0c
- move to raw docker #803752e
- server side and user client side completed for registration #848a611
- beginnings of download implementation #8674ac7
- more consistent naming for object handler #87230fb
- use autodevops build stage #886beb6
- Updated tailwind config #88c95d6
- change name of store file #8999303
- split prisma schemas #9011cf5
- client initiate #909432a
- more client routes to support Drop app update #91b7e10
- additional polish and QoL features #93bc143
- upload images to games #9b7ee4e
- migrate to pnpm due to ci/cd issues with yarn #9cb2d6d
- run yarn install in CI/CD non interactively #a208fbe
- completed game importing; partial work on version importing #a7c33e7
- remove canvas from dependencies #a8f58eb
- fix registry authentication #ad25d3e
- consolidate type utils #adb4b73
- Updated README.md #b0ef675
- add proper carousel to store page #b2ab827
- move to yarn v2 #b744671
- remove client API deadweight #b9ae26c
- add expires field #be6c30d
- ca groundwork #bfafd2a
- cleanup & polish #c355f6f
- remove bcrypt (debug) #c3914cc
- non rounded bottom #c4391d3
- failed gracefully on invalid chunk index #c4a3e4e
- update deploy template #c4a419f
- migrate to new droplet ca system #c4d8113
- docker based deployment #c5d00b4
- updated CONTRIBUTING.md #cd0d2bf
- update prisma version #ce0a9ab
- README update #ceacd84
- patch metadata handler #cf578bd
- Added SECURITY.md #d3d93b0
- finalised client APIs and authentication method #d4e2dc8
- Update README.md #db916bf
- object storage interface + utility functions #de388a9
- initial commit #e1a789f
- fixed task system #e1c1d7e
- Update file chunk.get.ts #e4339c3
- ui groundwork #e52f072
- check for no version in manifest generation #eb3f9f9
- break into single column store on lg devices #ecb381e
- better server side signin redirects #ef13b68
- patch signin #f3672f8
_changelog generated by_ [go-conventional-commits](https://github.com/joselitofilho/go-conventional-commits)

1
cli/.envrc Normal file
View file

@ -0,0 +1 @@
use flake

4
cli/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/target
logs/
.vscode
.direnv

3396
cli/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

27
cli/Cargo.toml Normal file
View file

@ -0,0 +1,27 @@
[package]
name = "downpour"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.100"
async-trait = "0.1.89"
chrono = "0.4.43"
clap = { version = "4.5.54", features = ["derive"] }
console = "0.16.2"
dialoguer = "0.12.0"
dirs = "6.0.0"
droplet-rs = { path = "../libraries/droplet" }
fern = { version = "0.7.1", features = ["colored"] }
futures = "0.3.31"
indicatif = "0.18.3"
log = "0.4.29"
opendal = { version = "0.55.0", features = ["services-s3"] }
rand = "0.9.3"
reqwest = { version = "0.13.1", features = ["json"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.148"
tokio = { version = "1.48.0", features = ["fs", "macros"] }
tokio-util = { version = "0.7.18", features = ["compat"] }
url = "2.5.8"
webbrowser = "1.0.6"

3
cli/README.md Normal file
View file

@ -0,0 +1,3 @@
# CLI (`downpour`)
The cli way to access Drop. Used for admin tasks that require local access, like uploading game content.

96
cli/flake.lock generated Normal file
View file

@ -0,0 +1,96 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1768564909,
"narHash": "sha256-Kell/SpJYVkHWMvnhqJz/8DqQg2b6PguxVWOuadbHCc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1744536153,
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1768704795,
"narHash": "sha256-Y33TAp2BHEcuspYvcmBXXD0qdvjftv73PwyKTDOjoSY=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "4b7472a78857ac789fb26616040f55cfcbd36c6e",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

52
cli/flake.nix Normal file
View file

@ -0,0 +1,52 @@
{
description = "Drop-OSS app development environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
rust-overlay.url = "github:oxalica/rust-overlay";
};
outputs =
{
self,
nixpkgs,
flake-utils,
rust-overlay,
}:
flake-utils.lib.eachDefaultSystem (
system:
let
overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs {
inherit system overlays;
};
libraries = with pkgs; [
glib
glibc
openssl
];
in
{
devShells.default = pkgs.mkShell {
nativeBuildInputs = with pkgs; [
pkg-config
git
rust-bin.nightly.latest.default
rust-analyzer
cargo-expand
];
buildInputs = libraries;
shellHook = ''
export LD_LIBRARY_PATH="${
pkgs.lib.makeLibraryPath libraries
}:$LD_LIBRARY_PATH"
echo "Downpour development environment loaded"
'';
};
}
);
}

2
cli/rust-toolchain.toml Normal file
View file

@ -0,0 +1,2 @@
[toolchain]
channel = "nightly"

10
cli/spec.md Normal file
View file

@ -0,0 +1,10 @@
# Downpour CLI spec
`downpour [command] --opts`
## Commands:
- new <path/s3 name> <public endpoint> - creates/initalizes a depot at the endpoint. Creates manifest.json and speedtest
- connect <s3 endpoint> <key> <secret> [name] - connects to an s3 endpoint and saves the endpoint to some sort of credentials file. Name is either as provided or the hostname of the endpoint
- upload <game id> <localpath> <path/s3 name> - uploads game as described before. Should fail if depot isn't initialized with new from above
- copy <game id> <version id> <src path/s3 name> <dest path/s3 name> - copies between two depots
- mark [exists/absent] <game id> <version id> <path/s3 name> - modifies depot's manifest.json to show content exists or is absent without copying (for third party copies)
- rename <public endpoint> <new public endpoint> - renames an endpoint [NEEDS API ROUTES - can't do yet]
- delete <public endpoint> - delete an endpoint [NEEDS API ROUTES - can't do yet]

69
cli/src/cli.rs Normal file
View file

@ -0,0 +1,69 @@
use clap::{Args, Parser, Subcommand, ValueEnum};
use crate::{commands::connect::config_option::ConfigOptionCli, interactive_variable};
#[derive(Parser)]
#[command(version, about, long_about = None)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
/// Specify data file path
#[arg(short, long)]
pub data: Option<String>,
}
#[derive(Subcommand)]
pub enum Commands {
/// Configures downpour endpoints
Connect {
#[arg(short, long)]
name: Option<String>,
#[command(subcommand)]
option: ConfigOptionCli,
},
/// Uploads new game version to depot
Upload {
#[clap(flatten)]
info: UploadInfoCli,
#[arg(short, long)]
/// Alias of a given connection
name: Option<String>,
},
}
#[derive(Args)]
pub struct UploadInfo {
pub path: String,
pub game_id: String,
pub version_id: String,
}
#[derive(Args)]
pub struct UploadInfoCli {
/// Relative path to new version files
#[arg(short, long, default_value_t = String::from("."))]
pub path: String,
/// ID of game to attach to
#[arg(short, long)]
pub game_id: Option<String>,
/// Version ID to attach to
#[arg(short, long)]
pub version_id: Option<String>,
}
impl UploadInfoCli {
pub fn interactive_configure(self) -> UploadInfo {
let path = self.path;
interactive_variable!(self, game_id, "Game ID");
interactive_variable!(self, version_id, "Version ID");
UploadInfo {
path,
game_id,
version_id,
}
}
}
#[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Eq)]
pub enum UploadStyle {
S3,
}

View file

@ -0,0 +1,152 @@
use crate::{
commands::connect::{
config_option::{ConfigOption, ConfigOptionCli},
configurable::Configure,
speedtest::{SPEEDTEST_PATH, Speedtest},
},
manifest::DepotManifest,
};
use dialoguer::{Confirm, theme::ColorfulTheme};
use futures::AsyncWriteExt;
use indicatif::{ProgressBar, ProgressStyle};
use log::{debug, info};
use opendal::Operator;
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, fs, ops::Not};
use tokio_util::compat::FuturesAsyncWriteCompatExt;
const CONFIG_DIR: &str = "downpour/config.json";
#[derive(Serialize, Deserialize)]
pub struct Config {
configurations: HashMap<String, ConfigOption>,
active: Option<String>,
}
impl Config {
pub fn new() -> Self {
Self {
configurations: HashMap::new(),
active: None,
}
}
pub fn exists(&self, name: &String) -> bool {
self.configurations.contains_key(name)
}
pub fn save(&self) -> anyhow::Result<()> {
let json = serde_json::to_string(self)?;
let save_path = dirs::config_dir()
.expect("Apparently your home directory doesn't exist") // Should probably formalise that error
.join(CONFIG_DIR);
fs::create_dir_all(save_path.parent().unwrap())?;
fs::write(save_path, json)?;
Ok(())
}
pub fn read() -> Self {
let save_path = dirs::config_dir()
.expect("Apparently your home directory doesn't exist") // Should probably formalise that error
.join(CONFIG_DIR);
if fs::exists(&save_path)
.unwrap_or_else(|_| panic!("Could not read save path {:#?}", &save_path))
{
serde_json::from_str(&fs::read_to_string(save_path).unwrap()).unwrap()
} else {
Config::new()
}
}
pub fn add_item(&mut self, name: String, object: ConfigOption) {
if matches!(object, ConfigOption::S3(..)) {
self.active = Some(name.clone())
}
self.configurations.insert(name, object);
self.save().expect("Failed to save config");
}
pub fn get_active(&self) -> Option<&ConfigOption> {
if let Some(active) = &self.active {
self.configurations.get(active)
} else {
None
}
}
pub fn get<T: AsRef<str>>(&self, name: T) -> Option<&ConfigOption> {
self.configurations.get(name.as_ref())
}
}
pub async fn manage_configuration(
config: &mut Config,
name: Option<String>,
option: ConfigOptionCli,
) -> anyhow::Result<()> {
let mut name = name;
if let Some(name) = &name
&& config.exists(name)
{
let confirm = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(format!(
"An entry already exists with the name \"{}\". Would you like to overwrite it?",
name
))
.interact()?;
if !confirm {
return Err(anyhow::anyhow!("User cancelled action"));
}
}
let config_option = match option {
ConfigOptionCli::S3(s3_config_cli) => s3_config_cli.clone().configure(&mut name).await?,
};
let name = name.expect("Default name was not provided by ConfigOption. This is a bug");
config.add_item(name, config_option.clone());
let operator = config_option.build()?;
generate_manifest(&operator).await?;
info!("Finished uploading manifest");
generate_speedtest(&operator).await?;
info!("Finished uploading speedtest");
Ok(())
}
async fn generate_speedtest(operator: &Operator) -> anyhow::Result<()> {
// Workaround to operator.exists("...") also logging a 404 warning
let lister = operator.list_with(SPEEDTEST_PATH).limit(1).await?;
if lister.is_empty().not() {
info!("Speedtest already exists on Depot. Skipping speedtest upload...");
return Ok(());
}
let mut writer = operator
.writer(SPEEDTEST_PATH)
.await?
.into_futures_async_write()
.compat_write();
let progress_bar = ProgressBar::new(10_000).with_style(
ProgressStyle::default_bar()
.template("[{elapsed_precise}] [ETA {eta}] {bar} {percent_precise}%")
.unwrap(),
);
let mut reader = Speedtest::new(|progress| {
let progress_int = (progress * 100f32).round() as u64;
progress_bar.set_position(progress_int);
});
let written = tokio::io::copy(&mut reader, &mut writer).await?;
progress_bar.finish();
debug!("Wrote {} bytes to {:?}", written, operator.info());
writer.into_inner().close().await?;
debug!("Closed writer");
Ok(())
}
async fn generate_manifest(operator: &Operator) -> anyhow::Result<()> {
let lister = operator.list_with("manifest.json").limit(1).await?;
if lister.is_empty().not() {
info!("Manifest already exists on Depot. Skipping manifest upload...");
return Ok(());
}
let data = DepotManifest::new();
operator
.write("manifest.json", serde_json::to_string(&data)?)
.await?;
Ok(())
}

View file

@ -0,0 +1,27 @@
use clap::Subcommand;
use opendal::{Operator, layers::LoggingLayer};
use serde::{Deserialize, Serialize};
use crate::{
commands::connect::s3::{S3Config, S3ConfigCli},
operator_builder::OperatorBuilder,
};
#[derive(Subcommand, Clone)]
pub enum ConfigOptionCli {
// Connect to any S3-compatible endpoint
S3(S3ConfigCli),
}
#[derive(Serialize, Deserialize, Clone)]
pub enum ConfigOption {
S3(S3Config),
}
impl ConfigOption {
pub fn build(&self) -> anyhow::Result<Operator> {
Ok(match self {
ConfigOption::S3(s3_config) => s3_config.build()?,
}
.layer(LoggingLayer::default()))
}
}

View file

@ -0,0 +1,5 @@
use crate::commands::connect::config_option::ConfigOption;
pub trait Configure {
async fn configure(self, name: &mut Option<String>) -> anyhow::Result<ConfigOption>;
}

View file

@ -0,0 +1,47 @@
use std::str::FromStr;
use dialoguer::{Input, theme::ColorfulTheme};
#[macro_export]
macro_rules! interactive_variable {
($value:ident, $var:ident, $prompt:expr) => {
let $var = if let Some($var) = $value.$var {
$var
} else {
$crate::commands::connect::interactive::query_variable($prompt).unwrap()
};
};
}
#[macro_export]
macro_rules! interactive_optional_variable {
($value:ident, $var:ident, $prompt:expr) => {
let $var = if let Some($var) = $value.$var {
Some($var)
} else {
$crate::commands::connect::interactive::query_optional_variable($prompt).unwrap()
};
};
}
pub fn query_variable<T: Clone + FromStr + ToString>(prompt: impl ToString) -> dialoguer::Result<T>
where
<T as FromStr>::Err: ToString,
{
Input::with_theme(&ColorfulTheme::default())
.with_prompt(prompt.to_string())
.interact_text()
}
pub fn query_optional_variable<T: Clone + FromStr + ToString>(
prompt: impl ToString,
) -> dialoguer::Result<Option<T>>
where
<T as FromStr>::Err: ToString,
{
let input: T = Input::with_theme(&ColorfulTheme::default())
.with_prompt(prompt.to_string())
.allow_empty(true)
.interact_text()?;
if input.to_string().is_empty() {
return Ok(None);
}
Ok(Some(input))
}

View file

@ -0,0 +1,7 @@
pub mod config;
pub mod configurable;
pub mod s3;
#[macro_use]
pub mod interactive;
pub mod config_option;
pub mod speedtest;

View file

@ -0,0 +1,67 @@
use clap::Args;
use opendal::Operator;
use serde::{Deserialize, Serialize};
use crate::{
commands::connect::{config_option::ConfigOption, configurable::Configure},
interactive_variable,
operator_builder::OperatorBuilder,
};
#[derive(Args, Clone)]
pub struct S3ConfigCli {
key_id: Option<String>,
secret_key: Option<String>,
endpoint: Option<String>,
region: Option<String>,
bucket_name: Option<String>,
root: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct S3Config {
key_id: String,
secret_key: String,
endpoint: String,
region: String,
bucket_name: String,
root: Option<String>,
}
impl Configure for S3ConfigCli {
async fn configure(self, name: &mut Option<String>) -> anyhow::Result<ConfigOption> {
interactive_variable!(self, key_id, "S3 Key ID");
interactive_variable!(self, secret_key, "S3 Secret Key");
interactive_variable!(self, region, "S3 Region");
interactive_variable!(self, bucket_name, "S3 Bucket Name");
interactive_variable!(self, endpoint, "S3 Endpoint");
if let None = name {
*name = Some(endpoint.clone());
}
Ok(ConfigOption::S3(S3Config {
secret_key,
key_id,
region,
bucket_name,
endpoint,
root: self.root,
}))
}
}
impl OperatorBuilder for S3Config {
fn build(&self) -> anyhow::Result<Operator> {
let builder = opendal::services::S3::default()
.access_key_id(&self.key_id)
.secret_access_key(&self.secret_key)
.region(&self.region)
.endpoint(&self.endpoint)
.root(self.root.as_deref().unwrap_or("/"))
.bucket(&self.bucket_name)
.disable_config_load();
let op: Operator = Operator::new(builder)?.finish();
Ok(op)
}
}

View file

@ -0,0 +1,41 @@
use rand::{RngCore, SeedableRng, rng, rngs::StdRng};
use tokio::io::AsyncRead;
#[derive(Clone, Debug)]
pub struct Speedtest<F: Fn(f32)> {
core: rand::rngs::StdRng,
to_write: usize,
callback: Box<F>,
}
pub const SPEEDTEST_BYTES: usize = 64 * 1024 * 1024;
pub const SPEEDTEST_PATH: &str = "speedtest";
impl<F: Fn(f32)> AsyncRead for Speedtest<F> {
fn poll_read(
self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
let mut s = self;
let to_write = buf.remaining().min(s.to_write);
let filled = {
let fill_slice = buf.initialize_unfilled_to(to_write);
s.core.fill_bytes(fill_slice);
fill_slice.len()
};
s.to_write = s.to_write.saturating_sub(filled);
(s.callback)((1f32 - (s.to_write as f32 / SPEEDTEST_BYTES as f32)) * 100f32);
buf.advance(filled);
std::task::Poll::Ready(Ok(()))
}
}
impl<F: Fn(f32)> Speedtest<F> {
pub fn new(callback: F) -> Self {
Self {
core: StdRng::from_rng(&mut rng()),
to_write: SPEEDTEST_BYTES,
callback: Box::new(callback),
}
}
}

2
cli/src/commands/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod connect;
pub mod upload;

View file

@ -0,0 +1,79 @@
use std::path::Path;
use crate::{
cli::UploadInfo,
commands::connect::{config::Config, config_option::ConfigOption},
manifest::{ClosureFactory, CompressionOption, DepotManifest, generate_v2_manifest},
operator_builder::OperatorBuilder,
};
use futures::AsyncWriteExt;
use log::info;
use opendal::{FuturesAsyncWriter, Operator};
use tokio_util::compat::{Compat, FuturesAsyncWriteCompatExt};
pub async fn upload(
upload_info: &UploadInfo,
config: Config,
name: &Option<String>,
) -> anyhow::Result<()> {
let game_id = upload_info.game_id.clone();
let path = upload_info.path.clone();
let version_id = upload_info.version_id.clone();
let operator = get_operator(config, name)?;
let mut existing_depot_manifest = get_depot_manifest(&operator).await?;
info!("Uploading chunks");
let v2_manifest = generate_v2_manifest(
Path::new(&path),
ClosureFactory::new(
async move |id: String| {
info!("Uploading chunk id {id}");
let writer = operator
.writer(&format!("{game_id}/{version_id}/{id}"))
.await
.unwrap()
.into_futures_async_write()
.compat_write();
writer
},
|writer: Compat<FuturesAsyncWriter>| async {
writer.into_inner().close().await.unwrap();
},
),
)
.await?;
info!("Finished uploading chunks");
existing_depot_manifest.append(
upload_info.game_id.to_string(),
upload_info.version_id.to_string(),
CompressionOption::None,
);
Ok(())
}
async fn get_depot_manifest(operator: &Operator) -> Result<DepotManifest, anyhow::Error> {
let existing_depot_manifest = operator.read("manifest.json").await?.to_bytes();
let existing_depot_manifest: DepotManifest =
serde_json::from_slice(existing_depot_manifest.as_ref())?;
Ok(existing_depot_manifest)
}
fn get_operator(config: Config, name: &Option<String>) -> anyhow::Result<Operator> {
let operator = match if let Some(name) = name {
config
.get(name)
.ok_or(anyhow::anyhow!("Name does not exist"))?
} else {
config.get_active().ok_or(anyhow::anyhow!(
"No active connection set. Please specify with --name"
))?
} {
ConfigOption::S3(s3_config) => s3_config.build()?,
};
Ok(operator)
}

View file

@ -0,0 +1 @@
pub mod interface;

53
cli/src/logging.rs Normal file
View file

@ -0,0 +1,53 @@
use fern::colors::{Color, ColoredLevelConfig};
use log::LevelFilter;
use std::env;
use std::fs;
use std::io;
pub fn configure_logging() -> anyhow::Result<()> {
let log_level = env::var("RUST_LOG")
.unwrap_or_else(|_| "info".to_string())
.parse::<LevelFilter>()?;
let log_dir = env::var("LOG_FILE_DIR").unwrap_or_else(|_| "logs".to_string());
fs::create_dir_all(&log_dir)?;
let colors = ColoredLevelConfig::new()
.error(Color::Red)
.warn(Color::Yellow)
.info(Color::Blue)
.debug(Color::Green)
.trace(Color::Magenta);
fern::Dispatch::new()
.chain(
fern::Dispatch::new()
.format(move |out, message, record| {
out.finish(format_args!(
"[{}] {}: {}",
chrono::Local::now().format("%H:%M:%S%.3f"),
colors.color(record.level()),
message
))
})
.chain(io::stdout()),
)
.chain(
fern::Dispatch::new()
.format(|out, message, record| {
out.finish(format_args!(
"[{}] {} {} - {}",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"),
record.level(),
record.target(),
message
))
})
.chain(fern::log_file(format!("{}/app.log", log_dir))?),
)
.level(log_level)
.apply()?;
Ok(())
}

34
cli/src/main.rs Normal file
View file

@ -0,0 +1,34 @@
#![feature(async_fn_traits)]
use crate::commands::connect::config::manage_configuration;
use crate::{
cli::{Cli, Commands},
commands::connect::config::Config,
commands::upload,
};
use clap::Parser;
mod cli;
mod commands;
mod logging;
mod manifest;
mod operator_builder;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
crate::logging::configure_logging()?;
let cli = Cli::parse();
let mut config = Config::read();
match cli.command {
Commands::Connect { name, option } => {
manage_configuration(&mut config, name, option).await?
}
Commands::Upload { info, name } => {
let info = info.interactive_configure();
upload::interface::upload(&info, config, &name).await?;
}
};
Ok(())
}

114
cli/src/manifest.rs Normal file
View file

@ -0,0 +1,114 @@
use std::{collections::HashMap, path::Path};
use async_trait::async_trait;
use droplet_rs::manifest::{Manifest, ManifestWriterFactory, generate_manifest_rusty};
use indicatif::{ProgressBar, ProgressStyle};
use log::info;
use serde::{Deserialize, Serialize};
use tokio::io::AsyncWrite;
#[derive(Serialize, Deserialize)]
pub struct DepotManifest {
content: HashMap<String, DepotManifestGameData>,
}
#[derive(Serialize, Deserialize)]
struct DepotManifestGameData {
version_id: String,
compression: CompressionOption,
}
#[derive(Serialize, Deserialize)]
pub enum CompressionOption {
None,
Gzip,
Zstd,
}
impl DepotManifest {
pub fn new() -> Self {
Self {
content: HashMap::new(),
}
}
pub fn append(&mut self, game_id: String, version_id: String, compression: CompressionOption) {
self.content.insert(
game_id,
DepotManifestGameData {
version_id,
compression,
},
);
}
}
pub struct ClosureFactory<Writer, Factory, Closer>
where
Writer: AsyncWrite + Unpin,
Factory: AsyncFn(String) -> Writer,
Closer: AsyncFn(Writer),
{
writer: Factory,
closer: Closer,
}
#[async_trait]
impl<
W: AsyncWrite + Unpin + Send + Sync,
F: AsyncFn(String) -> W + Send + Sync + 'static,
C: AsyncFn(W) + Send + Sync,
> ManifestWriterFactory for ClosureFactory<W, F, C>
where
for<'a> F::CallRefFuture<'a>: Send,
for<'b> C::CallRefFuture<'b>: Send,
{
type Writer = W;
async fn create(&self, id: String) -> anyhow::Result<Self::Writer> {
let func = &self.writer;
let output = func(id).await;
Ok(output)
}
async fn close(&self, writer: Self::Writer) -> anyhow::Result<()> {
let func = &self.closer;
func(writer).await;
Ok(())
}
}
impl<
W: AsyncWrite + Unpin + Send + Sync,
F: AsyncFn(String) -> W + Send + Sync + 'static,
C: AsyncFn(W) + Sync,
> ClosureFactory<W, F, C>
where
for<'a> F::CallRefFuture<'a>: Send,
for<'b> C::CallRefFuture<'b>: Send,
{
pub fn new(f: F, c: C) -> Self {
Self {
writer: f,
closer: c,
}
}
}
pub async fn generate_v2_manifest<Factory>(dir: &Path, factory: Factory) -> anyhow::Result<Manifest>
where
Factory: ManifestWriterFactory,
{
let progress_bar = ProgressBar::new(10_000).with_style(
ProgressStyle::default_bar()
.template("[{elapsed_precise}] [ETA {eta}] {bar} {percent_precise}%")
.unwrap(),
);
generate_manifest_rusty(
dir,
|progress| {
let progress_int = (progress * 100f32).round() as u64;
progress_bar.set_position(progress_int);
},
|log| progress_bar.suspend(|| info!("{}", log)),
Some(&factory),
None,
)
.await
}

View file

@ -0,0 +1,5 @@
use opendal::Operator;
pub trait OperatorBuilder {
fn build(&self) -> anyhow::Result<Operator>;
}

View file

@ -1,196 +0,0 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div v-if="game && unimportedVersions">
<div class="grow flex flex-row gap-y-8">
<div class="grow w-full h-full px-6 py-4 flex flex-col"></div>
<div
class="lg:overflow-y-auto lg:border-l lg:border-zinc-800 lg:block lg:inset-y-0 lg:z-50 lg:w-[30vw] flex flex-col gap-y-8 px-6 py-4"
>
<!-- version manager -->
<div>
<!-- version priority -->
<div>
<div class="border-b border-zinc-800 pb-3">
<div
class="flex flex-wrap items-center justify-between sm:flex-nowrap"
>
<h3
class="text-base font-semibold font-display leading-6 text-zinc-100"
>
{{ $t("library.admin.versionPriority") }}
<!-- import games button -->
<NuxtLink
:href="canImport ? `/admin/library/${game.id}/import` : ''"
type="button"
:class="[
canImport
? 'bg-blue-600 hover:bg-blue-700'
: 'bg-blue-800/50',
'inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1 text-sm font-semibold font-display text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600',
]"
>
{{
canImport
? $t("library.admin.import.version.import")
: $t("library.admin.import.version.noVersions")
}}
</NuxtLink>
</h3>
</div>
</div>
<div class="mt-4 text-center w-full text-sm text-zinc-600">
{{ $t("lowest") }}
</div>
<draggable
:list="game.versions"
handle=".handle"
class="mt-2 space-y-4"
@update="() => updateVersionOrder()"
>
<template
#item="{ element: item }: { element: GameVersionModel }"
>
<div
class="w-full inline-flex items-center px-4 py-2 bg-zinc-800 rounded justify-between"
>
<div class="text-zinc-100 font-semibold">
{{ item.versionName }}
</div>
<div class="text-zinc-400">
{{ item.delta ? $t("library.admin.version.delta") : "" }}
</div>
<div class="inline-flex items-center gap-x-2">
<component
:is="PLATFORM_ICONS[item.platform]"
class="size-6 text-blue-600"
/>
<Bars3Icon
class="cursor-move w-6 h-6 text-zinc-400 handle"
/>
<button @click="() => deleteVersion(item.versionName)">
<TrashIcon class="w-5 h-5 text-red-600" />
</button>
</div>
</div>
</template>
</draggable>
<div
v-if="game.versions.length == 0"
class="text-center font-bold text-zinc-400 my-3"
>
{{ $t("library.admin.version.noVersionsAdded") }}
</div>
<div class="mt-2 text-center w-full text-sm text-zinc-600">
{{ $t("highest") }}
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="grow w-full flex items-center justify-center">
<div class="flex flex-col items-center">
<ExclamationCircleIcon
class="h-12 w-12 text-red-600"
aria-hidden="true"
/>
<div class="mt-3 text-center sm:mt-5">
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
{{ $t("library.admin.offlineTitle") }}
</h1>
<div class="mt-4">
<p class="text-sm text-zinc-400 max-w-md">
{{ $t("library.admin.offline") }}
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { GameModel, GameVersionModel } from "~/prisma/client/models";
import { Bars3Icon, TrashIcon } from "@heroicons/vue/24/solid";
import type { SerializeObject } from "nitropack";
import type { H3Error } from "h3";
import { ExclamationCircleIcon } from "@heroicons/vue/24/outline";
// TODO implement UI for this page
const props = defineProps<{ unimportedVersions: string[] }>();
const { t } = useI18n();
const hasDeleted = ref(false);
const canImport = computed(
() => hasDeleted.value || props.unimportedVersions.length > 0,
);
type GameAndVersions = GameModel & { versions: GameVersionModel[] };
const game = defineModel<SerializeObject<GameAndVersions>>() as Ref<
SerializeObject<GameAndVersions>
>;
if (!game.value)
throw createError({
statusCode: 500,
statusMessage: "Game not provided to editor component",
});
async function updateVersionOrder() {
try {
const newVersions = await $dropFetch("/api/v1/admin/game/version", {
method: "PATCH",
body: {
id: game.value.id,
versions: game.value.versions.map((e) => e.versionName),
},
});
game.value.versions = newVersions;
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.version.order.title"),
description: t("errors.version.order.desc", {
error: (e as H3Error)?.statusMessage ?? t("errors.unknown"),
}),
buttonText: t("common.close"),
},
(e, c) => c(),
);
}
}
async function deleteVersion(versionName: string) {
try {
await $dropFetch("/api/v1/admin/game/version", {
method: "DELETE",
body: {
id: game.value.id,
versionName: versionName,
},
});
game.value.versions.splice(
game.value.versions.findIndex((e) => e.versionName === versionName),
1,
);
hasDeleted.value = true;
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.version.delete.title"),
description: t("errors.version.delete.desc", {
error: (e as H3Error)?.statusMessage ?? t("errors.unknown"),
}),
buttonText: t("common.close"),
},
(e, c) => c(),
);
}
}
</script>

View file

@ -1,115 +0,0 @@
<template>
<div>
<div class="inline-flex gap-1 items-center flex-wrap">
<span
v-for="item in enabledItems"
:key="item.param"
class="inline-flex items-center gap-x-0.5 rounded-md bg-blue-600/10 px-2 py-1 text-xs font-medium text-blue-500 ring-1 ring-blue-800 ring-inset"
>
{{ item.name }}
<button
type="button"
class="group relative -mr-1 size-3.5 rounded-xs hover:bg-blue-600/20"
@click="() => remove(item.param)"
>
<span class="sr-only">{{ $t("common.remove") }}</span>
<svg
viewBox="0 0 14 14"
class="size-3.5 stroke-blue-500 group-hover:stroke-blue-400"
>
<path d="M4 4l6 6m0-6l-6 6" />
</svg>
<span class="absolute -inset-1" />
</button>
</span>
<span
v-if="enabledItems.length == 0"
class="font-display uppercase text-xs font-bold text-zinc-700"
>
{{ $t("common.noSelected") }}
</span>
</div>
<Combobox as="div" @update:model-value="add">
<div class="relative mt-2">
<ComboboxInput
class="block w-full rounded-md bg-zinc-900 py-1.5 pr-12 pl-3 text-base text-zinc-100 outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-500 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
:display-value="(item) => (item as StoreSortOption)?.name"
placeholder="Start typing..."
@change="search = $event.target.value"
@blur="search = ''"
/>
<ComboboxButton
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-hidden"
>
<ChevronDownIcon class="size-5 text-gray-400" aria-hidden="true" />
</ComboboxButton>
<ComboboxOptions
v-if="filteredItems.length > 0 || search.length > 0"
class="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-hidden sm:text-sm"
>
<ComboboxOption
v-for="item in filteredItems"
:key="item.param"
v-slot="{ active }"
:value="item.param"
as="template"
>
<li
:class="[
'relative cursor-default py-2 pr-9 pl-3 select-none',
active
? 'bg-blue-600 text-white outline-hidden'
: 'text-zinc-100',
]"
>
<span class="block truncate">
{{ item.name }}
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</Combobox>
</div>
</template>
<script setup lang="ts">
import { ChevronDownIcon } from "@heroicons/vue/20/solid";
import {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
} from "@headlessui/vue";
const props = defineProps<{
items: Array<StoreSortOption>;
}>();
const model = defineModel<{ [key: string]: boolean }>();
const search = ref("");
const filteredItems = computed(() =>
props.items.filter(
(item) =>
!model.value?.[item.param] &&
item.name.toLowerCase().includes(search.value.toLowerCase()),
),
);
const enabledItems = computed(() =>
props.items.filter((e) => model.value?.[e.param]),
);
function add(item: string) {
search.value = "";
model.value ??= {};
model.value[item] = true;
}
function remove(item: string) {
model.value ??= {};
model.value[item] = false;
}
</script>

View file

@ -1,8 +0,0 @@
import { IconsLinuxLogo, IconsWindowsLogo, IconsMacLogo } from "#components";
import { PlatformClient } from "./types";
export const PLATFORM_ICONS = {
[PlatformClient.Linux]: IconsLinuxLogo,
[PlatformClient.Windows]: IconsWindowsLogo,
[PlatformClient.macOS]: IconsMacLogo,
};

View file

@ -1,13 +0,0 @@
import type { UserModel } from "~/prisma/client/models";
// undefined = haven't check
// null = check, no user
// {} = check, user
export const useUser = () => useState<UserModel | undefined | null>(undefined);
export const updateUser = async () => {
const user = useUser();
if (user.value === null) return;
user.value = await $dropFetch<UserModel | null>("/api/v1/user");
};

0
desktop/.bashrc Normal file
View file

34
desktop/.gitignore vendored Normal file
View file

@ -0,0 +1,34 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.nuxt
.output
src-tauri/flamegraph.svg
src-tauri/perf*
/*.AppImage
/squashfs-root
/target/

31
desktop/.gitlab-ci.yml Normal file
View file

@ -0,0 +1,31 @@
stages:
- build
build-linux:
stage: build
image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/rustlang/rust:nightly
script:
- apt-get update -y
- apt-get install yarnpkg libsoup-3.0-0 libsoup-3.0-dev libatk-adaptor libgtk-3-dev libjavascriptcoregtk-4.1-dev libwebkit2gtk-4.1-dev -y
- yarnpkg
- export
- export RUST_LOG=warn
- yarnpkg tauri build
- cp src-tauri/target/release/bundle/deb/*.deb .
- cp src-tauri/target/release/bundle/rpm/*.rpm .
- cp src-tauri/target/release/bundle/appimage/*.AppImage .
artifacts:
paths:
- "*.{deb,rpm,AppImage}"
build-windows:
stage: build
tags:
- windows
script:
- yarn
- yarn tauri build
- cp src-tauri/target/release/bundle/nsis/*.exe .
artifacts:
paths:
- "*.exe"

1
desktop/.nvmrc Normal file
View file

@ -0,0 +1 @@
23

7
desktop/.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,7 @@
{
"recommendations": [
"Vue.volar",
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer"
]
}

15
desktop/DEBUG.md Normal file
View file

@ -0,0 +1,15 @@
# How to create Flamegraph
Run this in `src-tauri`:
```
WEBKIT_DISABLE_DMABUF_RENDERER=1 CARGO_PROFILE_RELEASE_DEBUG=true cargo flamegraph --release
```
You can leave out `WEBKIT_DISABLE_DMABUF_RENDERER=1` if you're not on NVIDIA/Linux
And then run this in the root dir:
```
yarn dev --port 1432
```
And then do what you want, and it'll create the flamegraph for you

3
desktop/README.md Normal file
View file

@ -0,0 +1,3 @@
# Desktop
The official desktop client for Drop.

48
desktop/build.mjs Normal file
View file

@ -0,0 +1,48 @@
import fs from "fs";
import process from "process";
import childProcess from "child_process";
import createLogger from "pino";
const OUTPUT = "./.output";
const logger = createLogger({ transport: { target: "pino-pretty" } });
async function spawn(exec, opts) {
const output = childProcess.spawn(exec, { ...opts, shell: true });
output.stdout.on("data", (data) => {
process.stdout.write(data);
});
output.stderr.on("data", (data) => {
process.stderr.write(data);
});
return await new Promise((resolve, reject) => {
output.on("error", (err) => reject(err));
output.on("exit", () => resolve());
});
}
const views = fs.readdirSync(".").filter((view) => {
const expectedPath = `./${view}/package.json`;
return fs.existsSync(expectedPath);
});
fs.mkdirSync(OUTPUT, { recursive: true });
for (const view of views) {
const loggerChild = logger.child({});
process.chdir(`./${view}`);
loggerChild.info(`Install deps for "${view}"`);
await spawn("pnpm install");
loggerChild.info(`Building "${view}"`);
await spawn("pnpm run build", {
env: { ...process.env, NUXT_APP_BASE_URL: `/${view}/` },
});
process.chdir("..");
fs.cpSync(`./${view}/.output/public`, `${OUTPUT}/${view}`, {
recursive: true,
});
}

463
desktop/changelog.md Normal file
View file

@ -0,0 +1,463 @@
## Release 0.2.0-beta
### Fixes
- Re-enabled killing games #005bab2
- fixed queue manipulation and waiting for downloads #01260f0
- fix logic error in detecting dir #04368ff
- absolute executable invoke #17759c4
- don't crash download manager if multiple errors come in #21204de
- clear stale data before requesting new #327628b
- fixed completed indexes #39f2ebd
- add file & line to console logs #4d8eadc
- Games not launching due to string semantics #4ef49cc
- Added error handling for chunk request errors #4fc0855
- Chunk counting logic error #5ba151f
- modal stack doesn't cover whole app #5db9ae5
- use set_file_name instead of pushing to strings #60d0a48
- use of completed signal, and pause/resuming #64d7f64
- add message about nonce expiration #6a8d0af
- Added "LIbrary Failed to Update" content to recover from library load fail #76bae3d
- Restored RUST_LOG env functionality #7a0cf4f
- initialise doesn't recreate default install dir #7a3841b
- update routes for new server #7ab53f3
- use vendored flag #7c8089e
- fix poorly designed parsing for executables with spaces #7c90d2b
- assorted fixes #89ea34c
- Added Settings component #8aad64f
- windows build #8d9234f
- fix ugly scrollbars on edge webview #95f2174
- windows shadow #9a8cc59
- add better error message #9af0d08
- Broken command invoke logic in settings/downloads.vue #9e29aa7
- Accidentally was attempting to lock onto something that was already in scope #9e82a0b
- fix incorrect error assumptions & update types #a17311a
- Re-enabled uninstalling apps #a56ee25
- types #af056c0
- fix other metadata endpoints #c2f54c1
- Re-enabled deep links #c3f6222
- added console as an appender #d12bf15
- remove unnecessary unstable feature #d5ac1b0
- fix install button #d7b0302
- stop loading on error #d83aae6
- use unix timestamp to avoid invalid characters in filename #dafce24
- Renamed game_id to id #dceaa56
- use chrono library to generate timestamps #e22e6d8
- clear stale data before requesting new #e72662c
- fix scrollbars on edge webview #f09605a
- update readme instructions #f0c47d8
- Adding usize to completed_contexts_lock instead of &usize #f508186
### Features
- Game kill tauri command #01e6162
- add debug page #02f8591
- Add signout functionality (#16) #0a0d9d6
- queue and library UIs #0a20139
- add note about more install dirs #139bc0c
- Using SerializeDisplay for better error management with Result #170fde5
- add pre-launch log to file #17f8d76
- Added option to change root directory #1aa52c0
- add speed and time remaining information #1f899ec
- lockless tracking of downloaded chunks #2183585
- quit button #239b8d5
- use shift or DEBUG RUST_LOG to show Debug Info #245a84d
- Added database corruption dialog #25ba200
- only allow downloads for supported platforms #269dcbb
- add installed ui in the library menu #2c8164e
- added file-based logging #2d4a7e8
- automatically fetch remote data if not available #2dedfbb
- Added database recovery #32ae7d5
- ability to add more download dirs #384f7a5
- re-enable checksums #3ca87fc
- background processes and close/open menu #3d60fd5
- launch games with log files #3f71149
- Download cancelling #450bca9
- refactoring and error message #469a2d6
- Added UI to change download threads #4e93eb4
- Made save button include user feedback & only allow numeric characters #53234d2
- download widget and queue fix #532d13e
- Pausing and resuming game downloads #55b7921
- Allow settings to update UI using fetch_settings command #5bb04da
- temporary queue ui and flamegraph instructions #5cbeb3b
- Added DownloadThreadControl struct #5e05e68
- Added max_download_threads setting and separated settings from db #5ea47d7
- Added generic download manager #6159319
- Added AgentInterfaceData to get information about all downloads in queue #63c3cc1
- debug queue interface #671d45f
- reduce scope of download agent #6a38ea3
- Added multi-argument game launch and setup support #6ad3837
- shared child with stop command #6b96e40
- Added function to take and set any game state #6bc6482
- Added line numbers to file logging and highlighting to console #7c3140e
- Separated chunk updates into individual counters #7d3c601
- Ensure that any database issues are resolved by standalone functions #7d4651d
- ui to install games #8670bca
- Implemented spawning with umu (using umu-wrapper-lib) #88b2505
- offer manual signin #949acfc
- better process management, including running state #a135b13
- Added Download Manager #a1ada07
- retry connnection on server unavailable #a53d838
- finish download dir CRUD interface #a580a46
- better download manager errors + modal #ad92dbe
- syncs state to disk to persist across reboots #b556842
- prevent default context menu and emit event on elements #c560656
- initial creation and logo update #d9a51cf
- Added manifest.json utility for persistent download progress #d9d0122
- game uninstalling & partial compat #dd7f567
- combined db and download interface improvements #de52dac
- update db state with ui and emit events #e4df4eb
- Generic function to set download state #f10d92d
- Convert DownloadThreadControlFlag to AtomicBool #f25bfed
- add note about more install dirs #f4ac1c8
- Added rolling progress window #fd30b3e
### Other Changes
- quexeky <git@quexeky.dev>
- Convert DATA_ROOT_DIR to Mutex #00b7179
- Converting DB access to a trait #01b092c
- Updated changelog #022330b
- Progress on cleanup and exit #0381b8b
- library ui #03fa364
- Scoping changes and removing qualifications #046ba64
- Moved all files relevant to game downloads to their own directory #06d1e9e
- SLowly integrating game_download into the FE. Started with using the manifest minimal example in the server (#1) #07379b2
- Ran cargo clippy & moved DownloadManagerInterface #075d6ec
- Made logging systems match #0a1dddf
- Some easy cleanup of the download manager #0a2ac25
- client now fetches user information from Drop server #0c0cfeb
- Included in AppStatus (Also trying to link to Issue #1)
- Accidentally serialized AppStatus and broke everything :/ #10791ed
- Removed debugging statements #10c8344
- Wrappers are the bane of my existence. Also here's the download cancelling logic. #13df631
- Merge branch 'error-handling' #1520471
- Updated README.md #165a967
- Removed unnecessary dependencies #1724449
- merge(download-manager) -> 'main' #172d6b0
- More refactoring and renaming camelCase struct definitions to snake_case #1742793
- General cleanup #182361e
- Delete pages/library.vue #1861659
- progress on more precise download control #18b9149
- Allowing some dead code features because they are there for future use (potentially) #191e62c
- Ensure that Downloadable is also send and sync #1a89135
- I think that download queuing is working #1ab61c8
- auth initiate, database and more #22b1aee
- Update .gitlab-ci.yml" #2307704
- More fleshing out on how specifically game downloads will work (#1) #23137dd
- Removed utils.rs #270bc8b
- Fixing some references to "id" vs "game_id" #27e5a8e
- More cleanup after cargo clippy #2822b7a
- Updated contributing link #2aa5b9c
- More fleshing out on how specifically game downloads will work #2b90de9
- Cleaning up downloads playing and pausing #2c7b5fb
- fixed multi-chunk downloads #2ec351f
- Clippy refactoring #2efe304
- remove unpacker mod statement #32067c0
- Progress on adding tools #3299c71
- Fixed bug with bad initial loading into store instead of auth #3923acf
- add nvm rc #3ccd444
- partial download manager #3dbf5ab
- Update .gitlab-ci.yml with artifacts #3e10f17
- Removed tools/ #3eda979
- Downloads should be fixed now #403ca65
- transient vs synced state now defined #42c0198
- added adenmgb's autostart feature #472eb1d
- better download defaults #4779383
- Progress on downloads. Currently working on parsing functions to be run asynchronously #496c6a5
- Ran cargo clippy & cargo fmt #4983b25
- handshakes #4bb33c8
- Convert DOWNLOAD_MAX_THREADS to const #4fc13a1
- Merge branch 'downloads' #50ed841
- Moved generateGameMeta.ts to composables, using PathBuf instead of String for install_dirs #50f37fd
- Added time debugging and fixed logging formatting #5243694
- Clippy changes #553bc37
- Queue is running game downloads sequentially now #5564d23
- migrate to new droplet ca system #556898f
- Add LICENSE #57a5737
- ran cargo clippy & cargo fmt #5e3d26b
- my own take on some BASED design decisions #5ed0833
- cleanup and game UI beginnings #5ef6b8e
- Progress on terminator #5f5cbd0
- Implement better error system and segregate errors and commands (#23) #604d5b5
- moved to completed index arr to help serialization #64ebc19
- Ran cargo clippy & cargo fmt #653717e
- Removed all references to anything outside of the DownloadManager #6568faa
- Merge remote-tracking branch 'origin/main' #68ca4a7
- swap file name and to binary encoding #694f2fd
- chore(polish & cleanup) #6cc0c67
- Update .gitlab-ci.yml #6d7630e
- Moved some variable declarations outside of the spawned download thread #6ea4cf2
- Encoding game IDs and versions #6ef444e
- restructing and renaming #7049673
- Converted to md5 #706f525
- Merge branch 'main' into downloads #714b968
- Semantic naming changes #725f16b
- Abstracted queue system #76b0975
- Moved manifest and stored_manifest to download_manager" #78149bb
- README update #78fc668
- Ensured everything is serializing/deserializing to camelCase #7a95b7f
- fixed some of quexeky's BASED design decisions #7e3da04
- Progress checker works #7fec00d
- Progress on refactoring and abiding by cargo clippy #816b427
- Added GAME_PAUSE_CHECK_INTERVAL value #8204795
- Ran cargo clippy & fmt #82804eb
- update metadata #85a0899
- Renamed most instances of "game" outside of actual game downloads #881fcc6
- Debugging & starting work on parsing manifest #89d2814
- slight ui/ux fixes and updates to auth protocol #8a2d23d
- Removed Arc requirement for DownloadableMetadata #8be1dd4
- compliant with new APIs #8f6f184
- Ran cargo clippy & cargo fmt #9272970
- Added rolling_progress_updates.rs #9369ff1
- Add files via upload #93b8b83
- More refining info!() statements #94cf678
- fixed windows issues #959dad3
- Starting p2p progress #97bb1fa
- Game downloads from the client are working (multithreaded) by parsing in gameID, GameVersion, and maxThreads from FE (#1) #984472e
- Version bump & appimage build #9897698
- Some progress on thread terminations #99beca4
- rename files to what they contain #99c8b39
- Created separate function to generate requests #9a184a8
- cleanup of lib and toml #9b1cfa7
- refactor for generic way to implement cross platform launchers #9ea2aa4
- Updated logging format #a213765
- fix(windows build) #a24cc8a
- Added ToolDownloadAgent #a2e63aa
- copy direct to disk #a628fc1
- Moved manifest and stored_manifest to download_manager #a846eed
- adds nvm rc! #a881d8e
- Reordered DownloadThreadControlFlag to agree with From<bool> #ab606e8
- ci/cd and patches for windows builds #ac1c3b6
- patch for not draggable windows during setup #ac66b20
- another stage of client authentication #ae4c65b
- Renamed GameDonwloadError to ApplicationDownloadError and moved #aed58e4
- Progress on write speeds & added debug statements #b065e10
- Updated logging #b3963b6
- Created file settings.rs #b47b7ea
- Added Downloadable trait and replaced references to GameDownloadAgent #b4d70a3
- Update .gitlab-ci.yml #b6a54c0
- Moved download manager to separate directory #b6c64e5
- Ran cargo fmt #b8cf44c
- Imported appropriate logging macros #b99ff67
- Merge branch 'main' into download-manager #bb60942
- Ran cargo clippy & cargo fmt #bd3deac
- beginnings of game state management #bf46dec
- Update Cargo.toml #c1fb39e
- migrated unpacking to rust zstd to conform with droplet #c46c54b
- More progress on checksums #c51e761
- Delete pages/library.vue #c722a54
- Merge branch 'downloads' (again) #c748aec
- migrate to nuxt and groundwork #c957744
- More debugging because apparently checksums are the bane of my existence. But it works and I was just an idiot #c9d9d2e
- Fully separate & generic download manager #cac612b
- Progress on rolling progress window #cf19477
- Ensured that all logs start with lowercase capital and have no trailing punctuation #cfc9d13
- Validated that loading data works #d21b1d2
- Mostly finished with checksums. Just merging main in at the same time #d39e7cb
- Ran cargo clippy #dcb1564
- Add files via upload #dcb2c0f
- Theoretically adding queue support and optimistic manifest downloading (#1). Needs tests when actual functions are implemented #dcd8fa8
- Merge remote-tracking branch 'origin/downloads' into downloads #dd23ca8
- Debugging line #ddc585d
- Re-enabled closing the window and some more renaming #defba51
- drop no longer freaks out if server is unavailable on startup #df88395
- Apply stashed changes #e0ea8c9
- Merge remote-tracking branch 'origin/downloads' into downloads #e4e605b
- convert to more sensible permission schema #e504c00
- Update on GameDownload #e71e4cf
- reorganisation, cleanup and new nonce protocol #e828bca
- rustix fs feature #e9805a8
- Added manage_go_signal command #ea70ec9
- Drop will no longer crash when the server goes down #eb3311a
- Made all errors type-based #ec2f414
- Added description on how the DownloadManager works #f029cbf
- Using more appropriate logging statements #f183a9d
- remove unnecessary compat code (#20) #f1c8bbf
- Manifests are now being parsed successfully #f28c880
- Removed tests/ #f29e989
- I think that downloads are working. Need to test and set decent file locations now #f388237
- Just debugging tauri's damn Sync command features #f60ca2b
- fixes and patches for merged changes #f6476bc
- Added manage_queue_signal #f64782e
- initial commit #f6cd7c3
- Update .gitlab-ci.yml #fc6bab9
_changelog generated by_ [go-conventional-commits](https://github.com/joselitofilho/go-conventional-commits)
## Release 0.1.0-beta
### Fixes
- fixed queue manipulation and waiting for downloads #01260f0
- fix logic error in detecting dir #04368ff
- absolute executable invoke #17759c4
- Chunk counting logic error #5ba151f
- use of completed signal, and pause/resuming #64d7f64
- initialise doesn't recreate default install dir #7a3841b
- use vendored flag #7c8089e
- windows build #8d9234f
- windows shadow #9a8cc59
- types #af056c0
- added console as an appender #d12bf15
- remove unnecessary unstable feature #d5ac1b0
- use unix timestamp to avoid invalid characters in filename #dafce24
- use chrono library to generate timestamps #e22e6d8
- fix scrollbars on edge webview #f09605a
- update readme instructions #f0c47d8
### Features
- queue and library UIs #0a20139
- add pre-launch log to file #17f8d76
- Added option to change root directory #1aa52c0
- quit button #239b8d5
- only allow downloads for supported platforms #269dcbb
- added file-based logging #2d4a7e8
- automatically fetch remote data if not available #2dedfbb
- ability to add more download dirs #384f7a5
- background processes and close/open menu #3d60fd5
- launch games with log files #3f71149
- Download cancelling #450bca9
- refactoring and error message #469a2d6
- download widget and queue fix #532d13e
- Pausing and resuming game downloads #55b7921
- temporary queue ui and flamegraph instructions #5cbeb3b
- Added DownloadThreadControl struct #5e05e68
- Added AgentInterfaceData to get information about all downloads in queue #63c3cc1
- debug queue interface #671d45f
- reduce scope of download agent #6a38ea3
- Added function to take and set any game state #6bc6482
- Separated chunk updates into individual counters #7d3c601
- ui to install games #8670bca
- Added Download Manager #a1ada07
- retry connnection on server unavailable #a53d838
- finish download dir CRUD interface #a580a46
- syncs state to disk to persist across reboots #b556842
- prevent default context menu and emit event on elements #c560656
- initial creation and logo update #d9a51cf
- Added manifest.json utility for persistent download progress #d9d0122
- combined db and download interface improvements #de52dac
- update db state with ui and emit events #e4df4eb
- Generic function to set download state #f10d92d
- Convert DownloadThreadControlFlag to AtomicBool #f25bfed
### Other Changes
- quexeky <git@quexeky.dev>
- Convert DATA_ROOT_DIR to Mutex #00b7179
- Converting DB access to a trait #01b092c
- Scoping changes and removing qualifications #046ba64
- SLowly integrating game_download into the FE. Started with using the manifest minimal example in the server (#1) #07379b2
- Ran cargo clippy & moved DownloadManagerInterface #075d6ec
- Made logging systems match #0a1dddf
- client now fetches user information from Drop server #0c0cfeb
- Included in AppStatus (Also trying to link to Issue #1)
- Accidentally serialized AppStatus and broke everything :/ #10791ed
- Removed debugging statements #10c8344
- Wrappers are the bane of my existence. Also here's the download cancelling logic. #13df631
- Merge branch 'error-handling' #1520471
- Removed unnecessary dependencies #1724449
- merge(download-manager) -> 'main' #172d6b0
- More refactoring and renaming camelCase struct definitions to snake_case #1742793
- progress on more precise download control #18b9149
- Allowing some dead code features because they are there for future use (potentially) #191e62c
- I think that download queuing is working #1ab61c8
- auth initiate, database and more #22b1aee
- More fleshing out on how specifically game downloads will work (#1) #23137dd
- Removed utils.rs #270bc8b
- Fixing some references to "id" vs "game_id" #27e5a8e
- Updated contributing link #2aa5b9c
- More fleshing out on how specifically game downloads will work #2b90de9
- Cleaning up downloads playing and pausing #2c7b5fb
- fixed multi-chunk downloads #2ec351f
- Clippy refactoring #2efe304
- remove unpacker mod statement #32067c0
- Fixed bug with bad initial loading into store instead of auth #3923acf
- partial download manager #3dbf5ab
- Downloads should be fixed now #403ca65
- transient vs synced state now defined #42c0198
- better download defaults #4779383
- Progress on downloads. Currently working on parsing functions to be run asynchronously #496c6a5
- Ran cargo clippy & cargo fmt #4983b25
- handshakes #4bb33c8
- Convert DOWNLOAD_MAX_THREADS to const #4fc13a1
- Merge branch 'downloads' #50ed841
- Added time debugging and fixed logging formatting #5243694
- Clippy changes #553bc37
- Queue is running game downloads sequentially now #5564d23
- migrate to new droplet ca system #556898f
- Add LICENSE #57a5737
- ran cargo clippy & cargo fmt #5e3d26b
- my own take on some BASED design decisions #5ed0833
- cleanup and game UI beginnings #5ef6b8e
- moved to completed index arr to help serialization #64ebc19
- Ran cargo clippy & cargo fmt #653717e
- Merge remote-tracking branch 'origin/main' #68ca4a7
- swap file name and to binary encoding #694f2fd
- chore(polish & cleanup) #6cc0c67
- Encoding game IDs and versions #6ef444e
- restructing and renaming #7049673
- Converted to md5 #706f525
- Merge branch 'main' into downloads #714b968
- Semantic naming changes #725f16b
- Abstracted queue system #76b0975
- README update #78fc668
- Ensured everything is serializing/deserializing to camelCase #7a95b7f
- fixed some of quexeky's BASED design decisions #7e3da04
- Progress checker works #7fec00d
- Progress on refactoring and abiding by cargo clippy #816b427
- Added GAME_PAUSE_CHECK_INTERVAL value #8204795
- Debugging & starting work on parsing manifest #89d2814
- slight ui/ux fixes and updates to auth protocol #8a2d23d
- compliant with new APIs #8f6f184
- fixed windows issues #959dad3
- Starting p2p progress #97bb1fa
- Game downloads from the client are working (multithreaded) by parsing in gameID, GameVersion, and maxThreads from FE (#1) #984472e
- Some progress on thread terminations #99beca4
- rename files to what they contain #99c8b39
- cleanup of lib and toml #9b1cfa7
- Updated logging format #a213765
- fix(windows build) #a24cc8a
- copy direct to disk #a628fc1
- Reordered DownloadThreadControlFlag to agree with From<bool> #ab606e8
- ci/cd and patches for windows builds #ac1c3b6
- patch for not draggable windows during setup #ac66b20
- another stage of client authentication #ae4c65b
- Progress on write speeds & added debug statements #b065e10
- Updated logging #b3963b6
- Created file settings.rs #b47b7ea
- Ran cargo fmt #b8cf44c
- Merge branch 'main' into download-manager #bb60942
- Ran cargo clippy & cargo fmt #bd3deac
- beginnings of game state management #bf46dec
- Update Cargo.toml #c1fb39e
- migrated unpacking to rust zstd to conform with droplet #c46c54b
- More progress on checksums #c51e761
- Merge branch 'downloads' (again) #c748aec
- migrate to nuxt and groundwork #c957744
- More debugging because apparently checksums are the bane of my existence. But it works and I was just an idiot #c9d9d2e
- Validated that loading data works #d21b1d2
- Mostly finished with checksums. Just merging main in at the same time #d39e7cb
- Theoretically adding queue support and optimistic manifest downloading (#1). Needs tests when actual functions are implemented #dcd8fa8
- Merge remote-tracking branch 'origin/downloads' into downloads #dd23ca8
- Debugging line #ddc585d
- Re-enabled closing the window and some more renaming #defba51
- drop no longer freaks out if server is unavailable on startup #df88395
- Merge remote-tracking branch 'origin/downloads' into downloads #e4e605b
- convert to more sensible permission schema #e504c00
- Update on GameDownload #e71e4cf
- reorganisation, cleanup and new nonce protocol #e828bca
- rustix fs feature #e9805a8
- Drop will no longer crash when the server goes down #eb3311a
- Made all errors type-based #ec2f414
- Added description on how the DownloadManager works #f029cbf
- Manifests are now being parsed successfully #f28c880
- I think that downloads are working. Need to test and set decent file locations now #f388237
- Just debugging tauri's damn Sync command features #f60ca2b
- fixes and patches for merged changes #f6476bc
- initial commit #f6cd7c3
_changelog generated by_ [go-conventional-commits](https://github.com/joselitofilho/go-conventional-commits)

5
desktop/drop.svg Normal file
View file

@ -0,0 +1,5 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M4 13.5C4 11.0008 5.38798 8.76189 7.00766 7C8.43926 5.44272 10.0519 4.25811 11.0471 3.5959C11.6287 3.20893 12.3713 3.20893 12.9529 3.5959C13.9481 4.25811 15.5607 5.44272 16.9923 7C18.612 8.76189 20 11.0008 20 13.5C20 17.9183 16.4183 21.5 12 21.5C7.58172 21.5 4 17.9183 4 13.5Z"
stroke="#60a5fa" stroke-width="2" />
</svg>

After

Width:  |  Height:  |  Size: 440 B

View file

@ -0,0 +1,72 @@
import Foundation
import Security
enum SecurityError: Error {
case generalError
}
func deleteCertificateFromKeyChain(_ certificateLabel: String) -> Bool {
let delQuery: [NSString: Any] = [
kSecClass: kSecClassCertificate,
kSecAttrLabel: certificateLabel,
]
let delStatus: OSStatus = SecItemDelete(delQuery as CFDictionary)
return delStatus == errSecSuccess
}
func saveCertificateToKeyChain(_ certificate: SecCertificate, certificateLabel: String) throws {
SecKeychainSetPreferenceDomain(SecPreferencesDomain.system)
deleteCertificateFromKeyChain(certificateLabel)
let setQuery: [NSString: AnyObject] = [
kSecClass: kSecClassCertificate,
kSecValueRef: certificate,
kSecAttrLabel: certificateLabel as AnyObject,
kSecAttrAccessible: kSecAttrAccessibleWhenUnlocked,
kSecAttrCanSign: true as AnyObject,
]
let addStatus: OSStatus = SecItemAdd(setQuery as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw SecurityError.generalError
}
var status = SecTrustSettingsSetTrustSettings(certificate, SecTrustSettingsDomain.admin, nil)
}
func getCertificateFromString(stringData: String) throws -> SecCertificate {
if let data = NSData(base64Encoded: stringData, options: NSData.Base64DecodingOptions.ignoreUnknownCharacters) {
if let certificate = SecCertificateCreateWithData(kCFAllocatorDefault, data) {
return certificate
}
}
throw SecurityError.generalError
}
if CommandLine.arguments.count != 2 {
print("Usage: \(CommandLine.arguments[0]) [cert.file]")
print("Usage: \(CommandLine.arguments[0]) --version")
exit(1)
}
if (CommandLine.arguments[1] == "--version") {
let version = "dev"
print(version)
exit(0)
} else {
let fileURL = URL(fileURLWithPath: CommandLine.arguments[1])
do {
let certData = try Data(contentsOf: fileURL)
let certificate = SecCertificateCreateWithData(nil, certData as CFData)
if certificate != nil {
try? saveCertificateToKeyChain(certificate!, certificateLabel: "DropOSS")
exit(0)
} else {
print("ERROR: Unknown error while reading the \(CommandLine.arguments[1]) file.")
}
} catch {
print("ERROR: Unexpected error while reading the \(CommandLine.arguments[1]) file. \(error)")
}
}
exit(1)

51
desktop/main/app.vue Normal file
View file

@ -0,0 +1,51 @@
<template>
<NuxtLoadingIndicator color="#2563eb" />
<NuxtLayout class="select-none w-screen h-screen">
<NuxtPage />
<ModalStack />
</NuxtLayout>
</template>
<script setup lang="ts">
import "~/composables/downloads.js";
import { invoke } from "@tauri-apps/api/core";
import { useAppState } from "./composables/app-state.js";
import {
initialNavigation,
setupHooks,
} from "./composables/state-navigation.js";
import { listen } from "@tauri-apps/api/event";
import type { AppState } from "./types.js";
const router = useRouter();
const state = useAppState();
async function fetchState() {
try {
state.value = JSON.parse(await invoke("fetch_state"));
if (!state.value)
throw createError({
statusCode: 500,
statusMessage: `App state is: ${state.value}`,
fatal: true,
});
} catch (e) {
console.error("failed to parse state", e);
throw e;
}
}
await fetchState();
listen("update_state", (event) => {
state.value = event.payload as AppState;
});
setupHooks();
initialNavigation(state);
useHead({
title: "Drop",
});
</script>

View file

@ -0,0 +1,85 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
-ms-overflow-style: none; /* IE and Edge /
scrollbar-width: none; / Firefox */
overscroll-behavior: none;
}
/* Hide scrollbar for Chrome, Safari and Opera */
html::-webkit-scrollbar {
display: none;
}
$motiva: (
("MotivaSansThin.ttf", "ttf", 100, normal),
("MotivaSansLight.woff.ttf", "woff", 300, normal),
("MotivaSansRegular.woff.ttf", "woff", 400, normal),
("MotivaSansMedium.woff.ttf", "woff", 500, normal),
("MotivaSansBold.woff.ttf", "woff", 600, normal),
("MotivaSansExtraBold.ttf", "woff", 700, normal),
("MotivaSansBlack.woff.ttf", "woff", 900, normal)
);
$helvetica: (
("Helvetica.woff", "woff", 400, normal),
("Helvetica-Oblique.woff", "woff", 400, italic),
("Helvetica-Bold.woff", "woff", 600, normal),
("Helvetica-BoldOblique.woff", "woff", 600, italic),
("helvetica-light-587ebe5a59211.woff2", "woff2", 300, normal)
);
@each $file, $format, $weight, $style in $motiva {
@font-face {
font-family: "Motiva Sans";
src: url("/fonts/motiva/#{$file}") format($format);
font-weight: $weight;
font-style: $style;
}
}
@each $file, $format, $weight, $style in $helvetica {
@font-face {
font-family: "Helvetica";
src: url("/fonts/helvetica/#{$file}") format($format);
font-weight: $weight;
font-style: $style;
}
}
@font-face {
font-family: "Inter";
src: url("/fonts/inter/InterVariable.ttf");
font-style: normal;
}
@font-face {
font-family: "Inter";
src: url("/fonts/inter/InterVariable-Italic.ttf");
font-style: italic;
}
/* ===== Scrollbar CSS ===== */
/* Firefox */
* {
scrollbar-width: 4px;
scrollbar-color: #52525b #00000000;
}
/* Chrome, Edge, and Safari */
*::-webkit-scrollbar {
width: 4px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: #52525b;
border-radius: 10px;
border: 3px solid #52525b;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 MiB

View file

@ -0,0 +1,41 @@
<template>
<div v-if="rooms.length > 0" class="pt-3">
<p class="px-4 pb-1 text-[11px] uppercase tracking-wide text-zinc-500 font-semibold">
{{ label }}
</p>
<button
v-for="room in rooms"
:key="room.id"
@click="$emit('select', room.id)"
class="w-full flex items-center gap-x-2 px-4 py-2 text-left hover:bg-zinc-800/60 transition"
:class="active === room.id ? 'bg-zinc-800' : ''"
>
<CommunityAvatar v-if="room.kind === 'direct'" :url="room.otherUser?.avatarUrl ?? null" />
<HashtagIcon v-else class="h-5 w-5 text-zinc-500 shrink-0" />
<span class="text-sm text-zinc-200 truncate flex-1">{{ room.name }}</span>
<span
v-if="room.unreadCount > 0"
class="text-[11px] bg-blue-500 text-zinc-950 rounded-full min-w-[1.1rem] h-[1.1rem] px-1 text-center leading-[1.1rem] font-semibold"
>
{{ room.unreadCount }}
</span>
</button>
</div>
</template>
<script setup lang="ts">
import { HashtagIcon } from "@heroicons/vue/20/solid";
defineProps<{
label: string;
active?: string;
rooms: Array<{
id: string;
name: string;
kind: string;
unreadCount: number;
otherUser: { avatarUrl: string | null } | null;
}>;
}>();
defineEmits<{ select: [roomId: string] }>();
</script>

View file

@ -0,0 +1,26 @@
<template>
<img
v-if="url"
:src="useObject(url)"
:class="['rounded-sm bg-zinc-800 object-cover shrink-0', sizeClass]"
/>
<div
v-else
:class="['rounded-sm bg-zinc-800 flex items-center justify-center text-zinc-500 shrink-0', sizeClass]"
>
<UserIcon class="h-1/2 w-1/2" />
</div>
</template>
<script setup lang="ts">
import { UserIcon } from "@heroicons/vue/20/solid";
const props = withDefaults(
defineProps<{ url?: string | null; size?: "sm" | "md" | "lg" }>(),
{ size: "sm" },
);
const sizeClass = computed(() =>
props.size === "lg" ? "w-16 h-16" : props.size === "md" ? "w-10 h-10" : "w-7 h-7",
);
</script>

View file

@ -0,0 +1,30 @@
<script setup lang="ts">
import { StarIcon } from "@heroicons/vue/24/solid";
import { invoke } from "@tauri-apps/api/core";
const props = defineProps<{
path?: string;
}>();
const model = defineModel<string | undefined>({ required: true });
const isDefault = computed(() => props.path == model.value);
async function setDefault() {
if (!props.path) return;
await invoke("set_default", { path: props.path });
model.value = props.path;
}
</script>
<template>
<button
:class="['p-0.5 rounded-full', isDefault ? 'bg-blue-500' : 'bg-zinc-800']"
@click="setDefault"
:disabled="!props.path"
>
<StarIcon
:class="['size-[0.7rem]', isDefault ? 'text-zinc-100' : 'text-zinc-100']"
/>
</button>
</template>

View file

@ -0,0 +1,106 @@
<template>
<ModalTemplate :model-value="true">
<template #default
><div class="flex items-start gap-x-3">
<img :src="useObject(game.mIconObjectId)" class="size-12" />
<div class="mt-3 text-center sm:mt-0 sm:text-left">
<h3 class="text-base font-semibold text-zinc-100">
Missing required dependency "{{ game.mName }}"
</h3>
<div class="mt-2">
<p class="text-sm text-zinc-400">
To launch this game, you need to have "{{ game.mName }}" ({{
version.displayName ?? version.versionPath
}}) installed.
</p>
</div>
</div>
</div>
<InstallDirectorySelector
:install-dirs="installDirs"
v-model="installDir"
/>
<div v-if="installError" class="mt-1 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ installError }}
</h3>
</div>
</div>
</div>
</template>
<template #buttons>
<LoadingButton
@click="() => install()"
:loading="installLoading"
:disabled="installLoading"
type="submit"
class="ml-2 w-full sm:w-fit"
>
Install
</LoadingButton>
<button
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
@click="cancel"
ref="cancelButtonRef"
>
Cancel
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { XCircleIcon } from "@heroicons/vue/24/solid";
const model = defineModel<{ gameId: string; versionId: string }>({
required: true,
});
const { game, status } = await useGame(model.value.gameId);
const versionOptions = await invoke<Array<VersionOption>>(
"fetch_game_version_options",
{
gameId: game.id,
}
);
const version = versionOptions.find(
(v) => v.versionId === model.value.versionId
)!;
const installDirs = await invoke<string[]>("fetch_download_dir_stats");
const installDir = ref(0);
function cancel() {
// @ts-expect-error
model.value = undefined;
}
const installError = ref<string | undefined>();
const installLoading = ref(false);
async function install() {
try {
installLoading.value = true;
await invoke("download_game", {
gameId: game.id,
versionId: model.value.versionId,
installDir: installDir.value,
targetPlatform: version.platform,
});
cancel();
} catch (error) {
installError.value = (error as string).toString();
}
installLoading.value = false;
}
</script>

View file

@ -0,0 +1,141 @@
<template>
<Listbox
as="div"
v-model="model.overrideHandler"
class="mt-6"
v-if="handlers.length > 1"
>
<ListboxLabel class="block text-sm/6 font-medium text-white"
>Launch method</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="grid w-full cursor-default grid-cols-1 rounded-md bg-white/5 py-1.5 pr-2 pl-3 text-left text-white outline-1 -outline-offset-1 outline-white/10 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-500 sm:text-sm/6"
>
<span
v-if="currentHandler"
class="col-start-1 row-start-1 truncate pr-6"
>{{ currentHandler.name }}</span
>
<span
v-else
class="col-start-1 row-start-1 truncate pr-6 italic text-zinc-400"
>Automatic</span
>
<ChevronUpDownIcon
class="col-start-1 row-start-1 size-5 self-center justify-self-end text-zinc-400 sm:size-4"
aria-hidden="true"
/>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class=""
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-800 py-1 text-base outline-1 -outline-offset-1 outline-white/10 sm:text-sm"
>
<ListboxOption
as="template"
:value="undefined"
v-slot="{ active, selected }"
>
<li
:class="[
active ? 'bg-blue-500 text-white outline-hidden' : 'text-white',
'relative cursor-default py-2 pr-9 pl-3 select-none',
]"
>
<span
:class="[
selected ? 'font-semibold' : 'font-normal',
'block truncate italic',
]"
>Automatic</span
>
<span class="block truncate text-xs text-zinc-400"
>Pick the best method for this game.</span
>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-400',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
<ListboxOption
as="template"
v-for="handler in handlers"
:key="handler.id"
:value="handler.id"
v-slot="{ active, selected }"
>
<li
:class="[
active ? 'bg-blue-500 text-white outline-hidden' : 'text-white',
'relative cursor-default py-2 pr-9 pl-3 select-none',
]"
>
<span
:class="[
selected ? 'font-semibold' : 'font-normal',
'block truncate',
]"
>{{ handler.name }}</span
>
<span class="block truncate text-xs text-zinc-400">{{
handler.description
}}</span>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-400',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
<p class="mt-2 text-sm text-zinc-400">
Override how this game is launched.
</p>
</Listbox>
</template>
<script setup lang="ts">
import { invoke } from "@tauri-apps/api/core";
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { ChevronUpDownIcon } from "@heroicons/vue/16/solid";
import { CheckIcon } from "@heroicons/vue/20/solid";
import type { GameVersion } from "~/types";
type ProcessHandlerOption = { id: string; name: string; description: string };
const model = defineModel<GameVersion["userConfiguration"]>({ required: true });
const props = defineProps<{ gameId: string }>();
const handlers = await invoke<ProcessHandlerOption[]>("get_process_handlers", {
id: props.gameId,
});
const currentHandler = computed(() =>
handlers.find((v) => v.id == model.value.overrideHandler),
);
</script>

View file

@ -0,0 +1,41 @@
<template>
<div>
<label for="launch" class="block text-sm/6 font-medium text-zinc-100"
>Launch string template</label
>
<div class="mt-2">
<input
type="text"
name="launch"
id="launch"
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline-1 -outline-offset-1 outline-zinc-800 placeholder:text-zinc-400 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
placeholder="{}"
aria-describedby="launch-description"
v-model="model.launchTemplate"
/>
</div>
<p class="mt-2 text-sm text-zinc-400" id="launch-description">
Override the launch string. Passed to system's default shell, and replaces
"{}" with the command to start the game.
<span class="font-semibold text-zinc-200"
>Leaving it blank will cause the game not to start.</span
>
</p>
<ProtonSelector v-model="model" v-if="$props.protonEnabled" />
<HandlerSelector v-model="model" :game-id="$props.gameId" />
</div>
</template>
<script setup lang="ts">
import type { GameVersion } from "~/types";
import ProtonSelector from "./ProtonSelector.vue";
import HandlerSelector from "./HandlerSelector.vue";
const model = defineModel<GameVersion["userConfiguration"]>({ required: true });
const props = defineProps<{
protonEnabled: boolean;
gameId: string;
}>();
</script>

View file

@ -0,0 +1,189 @@
<template>
<Listbox
as="div"
v-model="model.overrideProtonPath"
class="mt-6"
>
<ListboxLabel class="block text-sm/6 font-medium text-white"
>Proton override</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="grid w-full cursor-default grid-cols-1 rounded-md bg-white/5 py-1.5 pr-2 pl-3 text-left text-white outline-1 -outline-offset-1 outline-white/10 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-500 sm:text-sm/6"
>
<span
v-if="currentProtonPath"
class="col-start-1 row-start-1 truncate pr-6"
>{{ currentProtonPath.name }} ({{ currentProtonPath.path }})</span
>
<span
v-else
class="col-start-1 row-start-1 truncate pr-6 italic text-zinc-400"
>No override configured</span
>
<ChevronUpDownIcon
class="col-start-1 row-start-1 size-5 self-center justify-self-end text-zinc-400 sm:size-4"
aria-hidden="true"
/>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class=""
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-800 py-1 text-base outline-1 -outline-offset-1 outline-white/10 sm:text-sm"
>
<ListboxOption
as="template"
:value="undefined"
v-slot="{ active, selected }"
>
<li
:class="[
active ? 'bg-blue-500 text-white outline-hidden' : 'text-white',
'relative cursor-default py-2 pr-9 pl-3 select-none',
]"
>
<span
:class="[
selected ? 'font-semibold' : 'font-normal',
'block truncate italic',
]"
>Use global default</span
>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-400',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
<h1 class="text-white text-sm font-semibold bg-zinc-900 py-2 px-2">
Auto-discovered
</h1>
<ListboxOption
as="template"
v-if="protonPaths.autodiscovered.length > 0"
v-for="proton in protonPaths.autodiscovered"
:key="proton.path"
:value="proton.path"
v-slot="{ active, selected }"
>
<li
:class="[
active ? 'bg-blue-500 text-white outline-hidden' : 'text-white',
'relative cursor-default py-2 pr-9 pl-3 select-none',
]"
>
<span
:class="[
selected ? 'font-semibold' : 'font-normal',
'block truncate',
]"
>{{ proton.name }} ({{ proton.path }})</span
>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-400',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
<li v-else class="italic text-zinc-400 py-2 pr-9 pl-3">
No auto-discovered layers.
</li>
<h1 class="text-white text-sm font-semibold bg-zinc-900 py-2 px-2">
Manually added
</h1>
<ListboxOption
as="template"
v-if="protonPaths.custom.length > 0"
v-for="proton in protonPaths.custom"
:key="proton.path"
:value="proton.path"
v-slot="{ active, selected }"
>
<li
:class="[
active ? 'bg-blue-500 text-white outline-hidden' : 'text-white',
'relative cursor-default py-2 pr-9 pl-3 select-none',
]"
>
<span
:class="[
selected ? 'font-semibold' : 'font-normal',
'block truncate',
]"
>{{ proton.name }} ({{ proton.path }})</span
>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-400',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
<li v-else class="italic text-zinc-400 py-2 pr-9 pl-3">
No manually added layers.
</li>
</ListboxOptions>
</transition>
</div>
<p class="mt-2 text-sm text-zinc-400" id="launch-description">
Override the Proton layer used to launch this game. You can add or remove
your custom Proton layer paths in
<PageWidget to="/settings/compat">
<WrenchIcon class="size-3" />
Settings </PageWidget
>.
</p>
</Listbox>
</template>
<script setup lang="ts">
import { invoke } from "@tauri-apps/api/core";
import type { ProtonPath } from "~/composables/game";
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { ChevronUpDownIcon } from "@heroicons/vue/16/solid";
import { CheckIcon } from "@heroicons/vue/20/solid";
import { WrenchIcon } from "@heroicons/vue/24/solid";
import type { GameVersion } from "~/types";
const model = defineModel<GameVersion["userConfiguration"]>({ required: true });
const protonPaths = await invoke<{
autodiscovered: ProtonPath[];
custom: ProtonPath[];
default?: string;
}>("fetch_proton_paths");
const currentProtonPath = computed(
() =>
protonPaths.autodiscovered.find(
(v) => v.path == model.value.overrideProtonPath,
) ??
protonPaths.custom.find((v) => v.path == model.value.overrideProtonPath),
);
</script>

View file

@ -0,0 +1,195 @@
<template>
<div class="space-y-6">
<div>
<h3 class="text-sm font-medium leading-6 text-zinc-100">Cloud saves</h3>
<p class="mt-1 text-sm leading-6 text-zinc-400">
Push a save folder to the server, or pull the most recent upload back
down. Drop doesn't know where this game keeps its saves yet, so pick
the folder yourself the first time -- it's remembered per slot for
this session.
</p>
</div>
<div v-if="loading" class="text-sm text-zinc-400">Loading save slots...</div>
<div v-else-if="loadError" class="rounded-md bg-red-600/10 p-4 text-sm text-red-500">
{{ loadError }}
</div>
<template v-else>
<ul role="list" class="divide-y divide-zinc-800">
<li
v-for="slot in slots"
:key="slot.index"
class="py-4 flex flex-col gap-2"
>
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-zinc-100">Slot {{ slot.index }}</p>
<p class="text-xs text-zinc-500">
{{ slot.historyObjectIds.length }} upload(s)
<span v-if="slot.createdAt"> &middot; created {{ slot.createdAt }}</span>
</p>
</div>
<button
type="button"
@click="() => removeSlot(slot.index)"
class="text-zinc-400 hover:text-red-500"
>
<TrashIcon class="size-5" aria-hidden="true" />
</button>
</div>
<div class="flex items-center gap-2">
<button
type="button"
@click="() => selectDir(slot.index)"
class="text-left flex-1 truncate rounded-md border-0 px-3 py-1.5 text-xs text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 bg-zinc-800 hover:ring-blue-600"
>
{{ savePaths[slot.index] ?? "Click to choose the save folder..." }}
</button>
<LoadingButton
:disabled="!savePaths[slot.index]"
:loading="busy[slot.index] === 'push'"
@click="() => push(slot.index)"
class="text-xs px-3 py-1.5"
>
Push
</LoadingButton>
<LoadingButton
:disabled="!savePaths[slot.index] || slot.historyObjectIds.length === 0"
:loading="busy[slot.index] === 'pull'"
@click="() => pull(slot.index)"
class="text-xs px-3 py-1.5"
>
Pull
</LoadingButton>
</div>
<p v-if="slotError[slot.index]" class="text-xs text-red-500">
{{ slotError[slot.index] }}
</p>
</li>
</ul>
<button
type="button"
@click="addSlot"
:disabled="creating"
class="inline-flex items-center gap-x-1 rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-900 disabled:opacity-50"
>
<PlusIcon class="size-4" aria-hidden="true" />
New save slot
</button>
</template>
</div>
</template>
<script setup lang="ts">
import { TrashIcon, PlusIcon } from "@heroicons/vue/20/solid";
import { invoke } from "@tauri-apps/api/core";
const props = defineProps<{ gameId: string }>();
type SaveSlot = {
gameId: string;
index: number;
createdAt?: string;
playtime: number;
historyObjectIds: string[];
historyChecksums: string[];
};
const slots = ref<SaveSlot[]>([]);
const loading = ref(true);
const loadError = ref<string | undefined>(undefined);
const creating = ref(false);
// Per-slot chosen save directory (session-only -- see the module doc in
// desktop/src-tauri/src/cloud_saves_commands.rs for why there's no
// auto-detected location to remember this against instead).
const savePaths = reactive<Record<number, string>>({});
const busy = reactive<Record<number, "push" | "pull" | undefined>>({});
const slotError = reactive<Record<number, string | undefined>>({});
async function refresh() {
loading.value = true;
loadError.value = undefined;
try {
slots.value = await invoke<SaveSlot[]>("fetch_save_slots", {
gameId: props.gameId,
});
} catch (e) {
loadError.value = `Could not load save slots: ${e}`;
} finally {
loading.value = false;
}
}
await refresh();
async function addSlot() {
creating.value = true;
try {
await invoke("create_save_slot", { gameId: props.gameId });
await refresh();
} catch (e) {
loadError.value = `Could not create save slot: ${e}`;
} finally {
creating.value = false;
}
}
async function removeSlot(index: number) {
try {
await invoke("delete_save_slot", { gameId: props.gameId, slotIndex: index });
await refresh();
} catch (e) {
slotError[index] = `Could not delete slot: ${e}`;
}
}
async function selectDir(index: number) {
try {
const dir = await invoke("plugin:dialog|open", {
options: { directory: true },
});
if (dir) savePaths[index] = dir as string;
} catch (e) {
slotError[index] = `Could not select directory: ${e}`;
}
}
async function push(index: number) {
const dir = savePaths[index];
if (!dir) return;
slotError[index] = undefined;
busy[index] = "push";
try {
await invoke("push_save_slot", {
gameId: props.gameId,
slotIndex: index,
saveDir: dir,
});
await refresh();
} catch (e) {
slotError[index] = `Push failed: ${e}`;
} finally {
busy[index] = undefined;
}
}
async function pull(index: number) {
const dir = savePaths[index];
if (!dir) return;
slotError[index] = undefined;
busy[index] = "pull";
try {
await invoke("pull_save_slot", {
gameId: props.gameId,
slotIndex: index,
saveDir: dir,
});
} catch (e) {
slotError[index] = `Pull failed: ${e}`;
} finally {
busy[index] = undefined;
}
}
</script>

View file

@ -0,0 +1,36 @@
<template>
<div class="space-y-8">
<div class="flex flex-row items-center justify-between">
<div>
<h3 class="text-sm font-medium leading-6 text-zinc-100">
Enable update checks
</h3>
<p class="mt-1 text-sm leading-6 text-zinc-400">
Drop will automatically check for updates from your server
</p>
</div>
<Switch
v-model="model.enableUpdates"
:class="[
model.enableUpdates ? 'bg-blue-600' : 'bg-zinc-700',
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out',
]"
>
<span
:class="[
model.enableUpdates ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none relative inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
]"
/>
</Switch>
</div>
</div>
</template>
<script setup lang="ts">
import { Switch } from "@headlessui/vue";
import type { GameVersion } from '~/types';
const model = defineModel<GameVersion["userConfiguration"]>({ required: true });
</script>

View file

@ -0,0 +1,149 @@
<template>
<ModalTemplate size-class="max-w-4xl" v-model="open">
<template #default>
<div class="flex flex-row gap-x-4 min-h-96">
<nav class="flex flex-1 flex-col" aria-label="Sidebar">
<ul role="list" class="-mx-2 space-y-1">
<li v-for="(tab, tabIdx) in tabs" :key="tab.name">
<button
@click="() => (currentTabIndex = tabIdx)"
:class="[
tabIdx == currentTabIndex
? 'bg-zinc-800 text-zinc-100'
: 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100',
'transition w-full group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
]"
>
<component
:is="tab.icon"
:class="[
tabIdx == currentTabIndex
? 'text-zinc-100'
: 'text-gray-400 group-hover:text-zinc-100',
'size-6 shrink-0',
]"
aria-hidden="true"
/>
{{ tab.name }}
</button>
</li>
</ul>
</nav>
<div class="border-l-2 border-zinc-800 w-full grow pl-4">
<component
v-model="configuration"
:is="tabs[currentTabIndex]?.page"
:proton-enabled="protonEnabled"
:game-id="props.gameId"
/>
</div>
</div>
<div v-if="saveError" class="mt-5 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ saveError }}
</h3>
</div>
</div>
</div>
</template>
<template #buttons>
<LoadingButton
@click="() => save()"
:loading="saveLoading"
type="submit"
class="ml-2 w-full sm:w-fit"
>
Save
</LoadingButton>
<button
@click="() => (open = false)"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
ref="cancelButtonRef"
>
Cancel
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import type { Component } from "vue";
import {
CloudIcon,
RocketLaunchIcon,
ServerIcon,
TrashIcon,
XCircleIcon,
} from "@heroicons/vue/20/solid";
import Launch from "./GameOptions/Launch.vue";
import Updates from "./GameOptions/Updates.vue";
import Saves from "./GameOptions/Saves.vue";
import { invoke } from "@tauri-apps/api/core";
import { ArrowPathIcon } from "@heroicons/vue/24/solid";
import type { GameVersion } from "~/types";
const appState = useAppState();
const open = defineModel<boolean>();
const props = defineProps<{ gameId: string }>();
const game = await useGame(props.gameId);
const configuration: Ref<GameVersion["userConfiguration"]> = ref(game.version.value!.userConfiguration);
const hasWindows = !!(
game.version.value!.setups.find((v) => v.platform === "Windows") ??
game.version.value!.launches.find((v) => v.platform === "Windows")
);
const protonEnabled = !!(
appState.value!.umuState !== "NotNeeded" && hasWindows
);
const tabs: Array<{ name: string; icon: Component; page: Component }> = [
{
name: "Launch",
icon: RocketLaunchIcon,
page: Launch,
},
{
name: "Updates",
icon: ArrowPathIcon,
page: Updates,
},
{
name: "Saves",
icon: CloudIcon,
page: Saves,
},
{
name: "Storage",
icon: ServerIcon,
page: h("div"),
},
];
const currentTabIndex = ref(0);
const saveLoading = ref(false);
const saveError = ref<undefined | string>();
async function save() {
saveLoading.value = true;
saveError.value = undefined;
try {
await invoke("update_game_configuration", {
gameId: game.game.id,
options: configuration.value,
});
open.value = false;
saveError.value = undefined;
} catch (e) {
saveError.value = (e as unknown as string).toString();
}
saveLoading.value = false;
}
</script>

View file

@ -0,0 +1,232 @@
<template>
<!-- Do not add scale animations to this: https://stackoverflow.com/a/35683068 -->
<div class="inline-flex divide-x divide-zinc-900">
<button
type="button"
@click="() => fetchStatusStyleData($props.status).action()"
:class="[
fetchStatusStyleData($props.status).style,
showDropdown ? 'rounded-l-md' : 'rounded-md',
'inline-flex uppercase font-display items-center gap-x-2 px-4 py-3 text-md font-semibold shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
]"
>
<component
:is="fetchStatusStyleData($props.status).icon"
class="-mr-0.5 size-5"
aria-hidden="true"
/>
{{ fetchStatusStyleData($props.status).buttonName }}
</button>
<Menu
v-if="showDropdown"
as="div"
class="relative inline-block text-left grow"
>
<div class="h-full">
<MenuButton
:class="[
fetchStatusStyleData($props.status).style,
'inline-flex w-full h-full justify-center items-center rounded-r-md px-1 py-2 text-sm font-semibold shadow-sm group',
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
]"
>
<ChevronDownIcon class="size-5" aria-hidden="true" />
</MenuButton>
</div>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute right-0 z-[500] mt-2 w-32 origin-top-right rounded-md bg-zinc-900 shadow-lg ring-1 ring-zinc-100/5 focus:outline-none"
>
<div class="py-1">
<MenuItem v-slot="{ active }">
<button
@click="() => emit('install')"
:class="[
active
? 'bg-zinc-800 text-zinc-100 outline-none'
: 'text-zinc-400',
'w-full px-4 py-2 text-sm inline-flex justify-between',
]"
>
Install
<ArrowDownTrayIcon class="size-5" />
</button>
</MenuItem>
<MenuItem v-if="showOptions" v-slot="{ active }">
<button
@click="() => emit('options')"
:class="[
active
? 'bg-zinc-800 text-zinc-100 outline-none'
: 'text-zinc-400',
'w-full px-4 py-2 text-sm inline-flex justify-between',
]"
>
Options
<Cog6ToothIcon class="size-5" />
</button>
</MenuItem>
<MenuItem v-slot="{ active }">
<button
@click="() => emit('uninstall')"
:class="[
active
? 'bg-zinc-800 text-zinc-100 outline-none'
: 'text-zinc-400',
'w-full inline-flex px-4 py-2 text-sm justify-between',
]"
>
Uninstall
<TrashIcon class="size-5" />
</button>
</MenuItem>
</div>
</MenuItems>
</transition>
</Menu>
</div>
</template>
<script setup lang="ts">
import {
ArrowDownTrayIcon,
ChevronDownIcon,
PlayIcon,
QueueListIcon,
ServerIcon,
StopIcon,
WrenchIcon,
} from "@heroicons/vue/20/solid";
import type { Component } from "vue";
import {
type EmptyGameStatusEnum,
InstalledType,
type GameStatus,
} from "~/types.js";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
import { Cog6ToothIcon, TrashIcon } from "@heroicons/vue/24/outline";
import { ArrowsRightLeftIcon, ArrowUpTrayIcon } from "@heroicons/vue/24/solid";
const props = defineProps<{ status: GameStatus }>();
const emit = defineEmits<{
(e: "install"): void;
(e: "launch"): void;
(e: "queue"): void;
(e: "uninstall"): void;
(e: "kill"): void;
(e: "options"): void;
(e: "resume"): void;
}>();
interface StatusStyleData {
style: string;
buttonName: string;
icon: Component;
action: () => void;
}
function fetchStatusStyleData(status: GameStatus): StatusStyleData {
if (status.type === "Installed") {
if (status.install_type.type === InstalledType.Installed) {
return {
style:
"bg-green-600 text-white hover:bg-green-500 focus-visible:outline-green-600 hover:bg-green-500",
buttonName: "Play",
icon: PlayIcon,
action: () => emit("launch"),
};
}
if (status.install_type.type === InstalledType.SetupRequired) {
return {
style:
"bg-yellow-600 text-white hover:bg-yellow-500 focus-visible:outline-yellow-600 hover:bg-yellow-500",
buttonName: "Setup",
icon: WrenchIcon,
action: () => emit("launch"),
};
}
if (status.install_type.type === InstalledType.PartiallyInstalled) {
return {
style:
"bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600 hover:bg-blue-500",
buttonName: "Resume",
icon: ArrowDownTrayIcon,
action: () => emit("resume"),
};
}
throw "Non-exhaustive install type: " + JSON.stringify(status.install_type);
}
return {
style: styles[status.type],
buttonName: buttonNames[status.type],
icon: buttonIcons[status.type],
action: buttonActions[status.type],
};
}
const showDropdown = computed(() => props.status.type === "Installed");
const showOptions = computed(
() =>
showDropdown.value &&
props.status.type === "Installed" &&
props.status.install_type.type !== InstalledType.PartiallyInstalled,
);
const styles: { [key in EmptyGameStatusEnum]: string } = {
Remote:
"bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600 hover:bg-blue-500",
Queued:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
Downloading:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
Validating:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
Updating:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
Uninstalling:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
Running:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
};
const buttonNames: { [key in EmptyGameStatusEnum]: string } = {
Remote: "Install",
Queued: "Queued",
Downloading: "Downloading",
Validating: "Validating",
Updating: "Updating",
Uninstalling: "Uninstalling",
Running: "Stop",
};
const buttonIcons: { [key in EmptyGameStatusEnum]: Component } = {
Remote: ArrowDownTrayIcon,
Queued: QueueListIcon,
Downloading: ArrowDownTrayIcon,
Validating: ServerIcon,
Updating: ArrowDownTrayIcon,
Uninstalling: TrashIcon,
Running: StopIcon,
};
const buttonActions: { [key in EmptyGameStatusEnum]: () => void } = {
Remote: () => emit("install"),
Queued: () => emit("queue"),
Downloading: () => emit("queue"),
Validating: () => emit("queue"),
Updating: () => emit("queue"),
Uninstalling: () => {},
Running: () => emit("kill"),
};
</script>

View file

@ -0,0 +1,85 @@
<template>
<div class="h-16 bg-zinc-950 flex flex-row justify-between">
<div class="flex flex-row grow items-center pl-5 pr-2 py-3">
<div class="inline-flex items-center gap-x-10">
<NuxtLink to="/store">
<Wordmark class="h-8 mb-0.5" />
</NuxtLink>
<nav class="inline-flex items-center mt-0.5">
<ol class="inline-flex items-center gap-x-6">
<NuxtLink
v-for="(nav, navIdx) in navigation"
:class="[
'transition uppercase font-display font-semibold text-md',
navIdx === currentNavigation
? 'text-zinc-100'
: 'text-zinc-400 hover:text-zinc-200',
]"
:href="nav.route"
>
{{ nav.label }}
</NuxtLink>
</ol>
</nav>
</div>
<div
@mousedown="() => window.startDragging()"
class="flex cursor-pointer grow h-full"
/>
<div class="inline-flex items-center">
<ol class="inline-flex gap-3">
<HeaderProtonSupportWidget />
<HeaderQueueWidget :object="currentQueueObject" />
<li>
<HeaderFriendsWidget />
</li>
<li>
<HeaderNotificationsWidget />
</li>
<OfflineHeaderWidget v-if="state?.status === AppStatus.Offline" />
<HeaderUserWidget />
</ol>
</div>
</div>
<WindowControl />
</div>
</template>
<script setup lang="ts">
import { AppStatus, type NavigationItem } from "../types";
import HeaderWidget from "./HeaderWidget.vue";
import HeaderFriendsWidget from "./HeaderFriendsWidget.vue";
import HeaderNotificationsWidget from "./HeaderNotificationsWidget.vue";
import { getCurrentWindow } from "@tauri-apps/api/window";
const window = getCurrentWindow();
const state = useAppState();
const navigation: Array<NavigationItem> = [
{
prefix: "/store",
route: "/store",
label: "Store",
},
{
prefix: "/library",
route: "/library",
label: "Library",
},
{
prefix: "/community",
route: "/community",
label: "Community",
},
{
prefix: "/news",
route: "/news",
label: "News",
},
];
const { currentNavigation } = useCurrentNavigationIndex(navigation);
const queue = useQueueState();
const currentQueueObject = computed(() => queue.value.queue.at(0));
</script>

View file

@ -0,0 +1,5 @@
<template>
<button class="transition h-full aspect-square text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100 p-[1.1rem]">
<slot />
</button>
</template>

View file

@ -0,0 +1,144 @@
<template>
<Menu as="div" class="relative inline-block">
<MenuButton>
<HeaderWidget :notifications="incomingCount > 0 ? incomingCount : undefined">
<UserGroupIcon class="h-5" />
</HeaderWidget>
</MenuButton>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute bg-zinc-900 right-0 top-10 z-50 w-96 origin-top-right focus:outline-none shadow-md rounded-md overflow-hidden"
>
<div class="flex items-center justify-between px-4 py-3 border-b border-zinc-800">
<h3 class="text-sm font-semibold text-zinc-100">Friends</h3>
<NuxtLink to="/community/friends" class="text-xs text-blue-500 hover:text-blue-400">
Manage
</NuxtLink>
</div>
<div class="max-h-96 overflow-y-auto">
<div v-if="!loaded" class="px-4 py-6 text-center text-sm text-zinc-500">
Loading&hellip;
</div>
<div v-else-if="error" class="px-4 py-4 text-xs text-red-500">
Couldn't load friends: {{ error }}
</div>
<template v-else>
<div v-if="friends.incomingRequests.length > 0" class="px-4 pt-3 pb-1">
<p class="text-[11px] uppercase tracking-wide text-zinc-500 font-semibold">
Requests
</p>
</div>
<div
v-for="req in friends.incomingRequests"
:key="req.id"
class="flex items-center gap-x-2 px-4 py-2"
>
<CommunityAvatar :url="req.user.avatarUrl" />
<span class="text-sm text-zinc-200 truncate flex-1">
{{ req.user.displayName }}
</span>
<button
@click="accept(req.id)"
class="text-xs text-green-500 hover:text-green-400 font-semibold"
>
Accept
</button>
<button
@click="decline(req.id)"
class="text-xs text-zinc-500 hover:text-zinc-300 font-semibold"
>
Decline
</button>
</div>
<div class="px-4 pt-3 pb-1" v-if="friends.friends.length > 0">
<p class="text-[11px] uppercase tracking-wide text-zinc-500 font-semibold">
{{ onlineCount }} online
</p>
</div>
<NuxtLink
v-for="friend in sortedFriends"
:key="friend.id"
:to="`/community/profile/${friend.username}`"
class="flex items-center gap-x-2 px-4 py-2 hover:bg-zinc-800/60"
>
<div class="relative">
<CommunityAvatar :url="friend.avatarUrl" />
<span
class="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-zinc-900"
:class="activity[friend.id]?.online ? 'bg-green-500' : 'bg-zinc-600'"
/>
</div>
<div class="min-w-0">
<p class="text-sm text-zinc-200 truncate">{{ friend.displayName }}</p>
<p class="text-xs text-zinc-500 truncate">
{{
activity[friend.id]?.playing
? `Playing ${activity[friend.id]?.playing?.name}`
: activity[friend.id]?.online
? "Online"
: "Offline"
}}
</p>
</div>
</NuxtLink>
<div
v-if="friends.friends.length === 0 && friends.incomingRequests.length === 0"
class="px-4 py-6 text-center text-sm text-zinc-500"
>
No friends yet.
<NuxtLink to="/community/friends" class="text-blue-500 hover:text-blue-400 block mt-1">
Find people to add
</NuxtLink>
</div>
</template>
</div>
</MenuItems>
</transition>
</Menu>
</template>
<script setup lang="ts">
import { Menu, MenuButton, MenuItems } from "@headlessui/vue";
import { UserGroupIcon } from "@heroicons/vue/20/solid";
import HeaderWidget from "./HeaderWidget.vue";
import { useFriends } from "~/composables/friends";
const { friends, activity, loaded, error, onlineCount, incomingCount, refresh, acceptRequest, declineRequest } =
useFriends();
refresh();
// Light polling for presence -- the friends REST list doesn't ride the
// community websocket (that's reserved for chat + presence broadcast to
// avoid every open dropdown independently subscribing/unsubscribing to
// presence:friends); this keeps "online now"/"playing X" reasonably fresh
// without wiring a second consumer onto the socket.
const interval = setInterval(refresh, 30_000);
onUnmounted(() => clearInterval(interval));
const sortedFriends = computed(() =>
[...friends.value.friends].sort((a, b) => {
const aOnline = activity.value[a.id]?.online ? 1 : 0;
const bOnline = activity.value[b.id]?.online ? 1 : 0;
return bOnline - aOnline;
}),
);
async function accept(id: string) {
await acceptRequest(id);
}
async function decline(id: string) {
await declineRequest(id);
}
</script>

View file

@ -0,0 +1,95 @@
<template>
<Menu as="div" class="relative inline-block">
<MenuButton>
<HeaderWidget :notifications="unreadCount > 0 ? unreadCount : undefined">
<BellIcon class="h-5" />
</HeaderWidget>
</MenuButton>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute bg-zinc-900 right-0 top-10 z-50 w-96 origin-top-right focus:outline-none shadow-md rounded-md overflow-hidden"
>
<div class="flex items-center justify-between px-4 py-3 border-b border-zinc-800">
<h3 class="text-sm font-semibold text-zinc-100">Notifications</h3>
<button
v-if="unreadCount > 0"
@click="markAllRead()"
class="text-xs text-blue-500 hover:text-blue-400"
>
Mark all read
</button>
</div>
<div class="max-h-96 overflow-y-auto">
<div v-if="!loaded" class="px-4 py-6 text-center text-sm text-zinc-500">
Loading&hellip;
</div>
<div v-else-if="error" class="px-4 py-4 text-xs text-red-500">
Couldn't load notifications: {{ error }}
</div>
<div
v-else-if="notifications.length === 0"
class="px-4 py-6 text-center text-sm text-zinc-500"
>
You're all caught up.
</div>
<button
v-else
v-for="n in notifications"
:key="n.id"
@click="() => !n.read && markRead(n.id)"
class="w-full text-left px-4 py-3 border-b border-zinc-800/60 last:border-0 hover:bg-zinc-800/60 transition"
:class="!n.read ? 'bg-blue-500/5' : ''"
>
<div class="flex items-start gap-x-2">
<div
class="mt-1 h-1.5 w-1.5 rounded-full shrink-0"
:class="n.read ? 'bg-transparent' : 'bg-blue-500'"
/>
<div class="min-w-0">
<p class="text-sm font-medium text-zinc-200 truncate">{{ n.title }}</p>
<p class="text-xs text-zinc-400 mt-0.5 line-clamp-2">{{ n.description }}</p>
<p class="text-[11px] text-zinc-600 mt-1">{{ formatDate(n.created) }}</p>
</div>
</div>
</button>
</div>
</MenuItems>
</transition>
</Menu>
</template>
<script setup lang="ts">
import { Menu, MenuButton, MenuItems } from "@headlessui/vue";
import { BellIcon } from "@heroicons/vue/20/solid";
import HeaderWidget from "./HeaderWidget.vue";
import { useNotifications } from "~/composables/notifications";
const { notifications, loaded, error, unreadCount, startPolling, markRead, markAllRead } =
useNotifications();
startPolling();
function formatDate(iso: string) {
try {
const date = new Date(iso);
const diffMs = Date.now() - date.getTime();
const diffMin = Math.round(diffMs / 60000);
if (diffMin < 1) return "just now";
if (diffMin < 60) return `${diffMin}m ago`;
const diffHr = Math.round(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
return date.toLocaleDateString();
} catch {
return iso;
}
}
</script>

View file

@ -0,0 +1,24 @@
<template>
<NuxtLink
v-if="onLinux"
to="/settings/compat"
>
<HeaderWidget :problem="protonError">
<img
src="/proton-logo.png"
class="relative z-50 size-5 brightness-[30%]"
/>
</HeaderWidget>
</NuxtLink>
</template>
<script setup lang="ts">
const appState = useAppState();
const onLinux = appState.value?.umuState !== "NotNeeded";
const paths = onLinux ? await useProtonPaths() : undefined;
const protonError = computed(
() =>
appState.value?.umuState === "NotInstalled" || !paths?.data.value.default,
);
</script>

View file

@ -0,0 +1,26 @@
<script setup lang="ts">
import { ArrowDownTrayIcon } from "@heroicons/vue/20/solid";
const props = defineProps<{ object?: QueueState["queue"][0] }>();
</script>
<template>
<NuxtLink
to="/queue"
class="transition inline-flex items-center cursor-pointer rounded-sm px-4 py-1.5 bg-zinc-900 hover:bg-zinc-800 relative"
>
<ArrowDownTrayIcon
:class="[
'h-5 z-50',
props.object
? 'text-white hover:text-zinc-300'
: 'text-zinc-600 hover:text-zinc-300',
]"
/>
<div
v-if="props.object?.dl_progress"
class="transition-all absolute left-0 top-0 bottom-0 bg-blue-600 z-10"
:style="{ width: `${props.object.dl_progress * 99 + 1}%` }"
/>
</NuxtLink>
</template>

View file

@ -0,0 +1,113 @@
<template>
<Menu v-if="state?.user" as="div" class="relative inline-block">
<MenuButton>
<HeaderWidget>
<div class="inline-flex items-center text-zinc-300 hover:text-white">
<img :src="profilePictureUrl" class="w-5 h-5 rounded-sm" />
<span class="ml-2 text-sm font-bold">{{
state.user.displayName
}}</span>
<ChevronDownIcon class="ml-3 h-4" />
</div>
</HeaderWidget>
</MenuButton>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute bg-zinc-900 right-0 top-10 z-50 w-56 origin-top-right focus:outline-none shadow-md"
>
<div class="flex-col gap-y-2">
<NuxtLink
:to="`/community/profile/${state.user.username}`"
class="transition inline-flex items-center w-full py-3 px-4 hover:bg-zinc-800"
>
<div class="inline-flex items-center text-zinc-300">
<img :src="profilePictureUrl" class="w-5 h-5 rounded-sm" />
<span class="ml-2 text-sm font-bold">{{
state.user.displayName
}}</span>
</div>
</NuxtLink>
<div class="h-0.5 rounded-full w-full bg-zinc-800" />
<div class="flex flex-col mb-1">
<MenuItem v-if="state.user.admin" v-slot="{ active }">
<a
:href="adminUrl"
target="_blank"
:class="[
active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400',
'transition block px-4 py-2 text-sm',
]"
>
Admin Dashboard
</a>
</MenuItem>
<MenuItem
v-for="(nav, navIdx) in navigation"
v-slot="{ active, close }"
>
<button
@click="() => navigate(close, nav)"
:href="nav.route"
:class="[
active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400',
'transition text-left block px-4 py-2 text-sm',
]"
>
{{ nav.label }}
</button>
</MenuItem>
</div>
</div>
</MenuItems>
</transition>
</Menu>
</template>
<script setup lang="ts">
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
import { ChevronDownIcon } from "@heroicons/vue/16/solid";
import type { NavigationItem } from "../types";
import HeaderWidget from "./HeaderWidget.vue";
import { useAppState } from "~/composables/app-state";
import { invoke } from "@tauri-apps/api/core";
const open = ref(false);
const router = useRouter();
router.afterEach(() => {
open.value = false;
});
const state = useAppState();
const profilePictureUrl: string = await useObject(
state.value?.user?.profilePictureObjectId ?? ""
);
const adminUrl: string = await invoke("gen_drop_url", {
path: "/admin",
});
function navigate(close: () => any, to: NavigationItem) {
close();
router.push(to.route);
}
const navigation: NavigationItem[] = [
{
label: "App settings",
route: "/settings",
prefix: "",
},
{
label: "Quit Drop",
route: "/quit",
prefix: "",
},
];
</script>

View file

@ -0,0 +1,34 @@
<template>
<div
:class="[
'transition inline-flex items-center cursor-pointer rounded-sm px-4 py-1.5 text-zinc-600 hover:text-zinc-300 relative',
props.notifications !== undefined
? 'bg-blue-400'
: props.problem !== undefined && props.problem
? 'bg-red-400'
: 'bg-zinc-900 hover:bg-zinc-800',
]"
>
<slot />
<div
v-if="props.notifications !== undefined"
class="text-zinc-900 absolute top-0 right-0 translate-x-[30%] translate-y-[-30%] text-xs bg-blue-400 rounded-full w-3.5 h-3.5 text-center"
>
{{ props.notifications }}
</div>
<div
v-else-if="props.problem !== undefined && props.problem"
class="text-zinc-100 absolute top-0 right-0 translate-x-[30%] translate-y-[-30%] text-sm bg-red-400 rounded-full w-5 h-5 text-center"
>
!
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
notifications?: number;
problem?: boolean;
class?: string;
}>();
</script>

View file

@ -0,0 +1,171 @@
<template>
<div
class="grid min-h-full grid-cols-1 grid-rows-[1fr,auto,1fr] lg:grid-cols-[max(50%,36rem),1fr]"
>
<header
class="mx-auto w-full max-w-7xl px-6 pt-6 sm:pt-10 lg:col-span-2 lg:col-start-1 lg:row-start-1 lg:px-8"
>
<Logo class="h-10 w-auto sm:h-12" />
</header>
<main
class="mx-auto w-full max-w-7xl px-6 py-24 sm:py-32 lg:col-span-2 lg:col-start-1 lg:row-start-2 lg:px-8"
>
<div class="max-w-lg">
<slot />
<div class="mt-10">
<div>
<div v-if="loading" role="status">
<svg
aria-hidden="true"
class="w-5 h-5 text-transparent animate-spin fill-white"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span class="sr-only">Loading...</span>
</div>
<span class="inline-flex gap-x-8 items-center" v-else>
<button
@click="() => authWrapper_wrapper()"
:disabled="loading"
class="px-3 py-1 inline-flex items-center gap-x-2 bg-zinc-700 rounded text-sm text-left font-semibold leading-7 text-white"
>
Sign in with your browser <ArrowTopRightOnSquareIcon class="size-4" />
</button>
<NuxtLink href="/auth/code" class="text-zinc-100 text-sm hover:text-zinc-300">
Use a code &rarr;
</NuxtLink>
</span>
</div>
<div class="mt-5" v-if="offerManual">
<h1 class="text-zinc-100 font-semibold">Having trouble?</h1>
<p class="mt-1 text-zinc-400 text-sm">
You can manually enter the token from your web browser.
</p>
<div class="inline-flex gap-x-1 mt-2 w-full">
<input
id="token"
name="token"
type="text"
autocomplete="token"
required
class="grow block w-full rounded-md border-0 py-1.5 px-3 shadow-sm bg-zinc-950/20 text-zinc-300 ring-1 ring-inset ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
v-model="manualToken"
/>
<LoadingButton
:loading="manualLoading"
@click="() => continueManual_wrapper()"
class="w-fit"
>
Submit
</LoadingButton>
</div>
</div>
<div v-if="error" class="mt-5 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ error }}
</h3>
</div>
</div>
</div>
</div>
</div>
</main>
<footer class="self-end lg:col-span-2 lg:col-start-1 lg:row-start-3">
<div class="border-t border-blue-600 bg-zinc-900 py-10">
<nav
class="mx-auto flex w-full max-w-7xl items-center gap-x-4 px-6 text-sm leading-7 text-zinc-400 lg:px-8"
>
<a href="#">Documentation</a>
<svg
viewBox="0 0 2 2"
aria-hidden="true"
class="h-0.5 w-0.5 fill-zinc-700"
>
<circle cx="1" cy="1" r="1" />
</svg>
<a href="#">Troubleshooting</a>
<svg
viewBox="0 0 2 2"
aria-hidden="true"
class="h-0.5 w-0.5 fill-zinc-700"
>
<circle cx="1" cy="1" r="1" />
</svg>
<NuxtLink to="/setup/server">Switch instance</NuxtLink>
</nav>
</div>
</footer>
<div
class="hidden lg:relative lg:col-start-2 lg:row-start-1 lg:row-end-4 lg:block"
>
<img
src="@/assets/wallpaper.jpg"
alt=""
class="absolute inset-0 h-full w-full object-cover"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { XCircleIcon } from "@heroicons/vue/16/solid";
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/20/solid";
import { invoke } from "@tauri-apps/api/core";
const loading = ref(false);
const error = ref<string | undefined>();
let offerManualTimeout: NodeJS.Timeout | undefined;
const offerManual = ref(false);
const manualToken = ref("");
const manualLoading = ref(false);
async function auth() {
await invoke("auth_initiate");
}
function authWrapper_wrapper() {
error.value = undefined;
loading.value = true;
auth().catch((e) => {
loading.value = false;
error.value = e;
if (offerManualTimeout) clearTimeout(offerManualTimeout);
});
offerManualTimeout = setTimeout(() => {
offerManual.value = true;
}, 2000);
}
async function continueManual() {
await invoke("manual_recieve_handshake", { token: manualToken.value });
}
function continueManual_wrapper() {
loading.value = true;
continueManual()
.catch((e) => {
error.value = e;
})
.finally(() => {
loading.value = false;
});
}
</script>

View file

@ -0,0 +1,87 @@
<template>
<Listbox as="div" v-model="installDir">
<ListboxLabel class="block text-sm/6 font-medium text-zinc-100"
>Install to</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-800 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm/6"
>
<span class="block truncate">{{ installDirs[installDir] }}</span>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<ListboxOption
as="template"
v-for="(dir, dirIdx) in installDirs"
:key="dir"
:value="dirIdx"
v-slot="{ active, selected }"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-300',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
selected ? 'font-semibold text-zinc-100' : 'font-normal',
'block truncate',
]"
>{{ dir }}</span
>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
<div class="text-zinc-400 text-sm mt-2">
Add more install directories in
<PageWidget to="/settings/downloads">
<WrenchIcon class="size-3" />
Settings
</PageWidget>
</div>
</Listbox>
</template>
<script setup lang="ts">
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import {
CheckIcon,
ChevronUpDownIcon,
WrenchIcon,
} from "@heroicons/vue/20/solid";
const installDir = defineModel<number>({ required: true });
const { installDirs } = defineProps<{ installDirs: string[] }>();
</script>

View file

@ -0,0 +1,367 @@
<template>
<div class="flex flex-col h-full">
<div class="mb-3 inline-flex gap-x-2">
<div
class="relative transition-transform duration-300 hover:scale-105 active:scale-95"
>
<div
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"
>
<MagnifyingGlassIcon
class="h-5 w-5 text-zinc-400"
aria-hidden="true"
/>
</div>
<input
type="text"
v-model="searchQuery"
class="block w-full rounded-lg border-0 bg-zinc-800/50 py-2 pl-10 pr-3 text-zinc-100 placeholder:text-zinc-500 focus:bg-zinc-800 focus:ring-2 focus:ring-inset focus:ring-blue-500 sm:text-sm sm:leading-6"
placeholder="Search library..."
/>
</div>
<button
@click="() => calculateGames(true, true)"
class="p-1 flex items-center justify-center transition-transform duration-300 size-10 hover:scale-110 active:scale-90 rounded-lg bg-zinc-800/50 text-zinc-100"
>
<ArrowPathIcon class="size-4" />
</button>
</div>
<TransitionGroup
name="list"
tag="ul"
class="flex flex-col gap-y-1.5 h-full"
>
<Disclosure
as="div"
v-for="(nav, navIndex) in filteredNavigation"
:key="nav.id"
:class="['first:pt-0 last:pb-0', nav.tools && !filteredNavigation[navIndex - 1].tools ? 'mt-auto' : '']"
v-slot="{ open }"
:default-open="nav.deft"
>
<dt>
<DisclosureButton
class="flex w-full items-center justify-between text-left text-gray-900 dark:text-white"
>
<span class="text-sm font-semibold font-display">{{
nav.name
}}</span>
<span class="ml-6 relative flex size-4">
<MinusIcon class="absolute inset-0 size-4" aria-hidden="true" />
<MinusIcon
:class="[
!open ? 'rotate-90' : 'rotate-0',
'transition-all absolute inset-0 size-4',
]"
aria-hidden="true"
/>
</span>
</DisclosureButton>
</dt>
<DisclosurePanel as="dd" class="mt-2 flex flex-col gap-y-1.5">
<NuxtLink
v-for="item in nav.items"
:key="nav.id"
:class="[
'transition-all duration-300 rounded-lg flex items-center px-1 py-0.5 hover:scale-105 active:scale-95 hover:shadow-lg hover:shadow-zinc-950/50',
currentNavigation == item.id
? 'bg-zinc-800 text-zinc-100 shadow-md shadow-zinc-950/20'
: item.isInstalled.value
? 'text-zinc-300 hover:bg-zinc-800/90 hover:text-zinc-200'
: 'text-zinc-500 hover:bg-zinc-800/70 hover:text-zinc-300',
]"
:href="item.route"
>
<div class="flex items-center w-full gap-x-2">
<div
class="flex-none transition-transform duration-300 hover:-rotate-2"
>
<img
class="size-6 object-cover bg-zinc-900 rounded transition-all duration-300 shadow-sm"
:src="useObject(item.icon)"
alt=""
/>
</div>
<div class="truncate flex flex-col">
<p class="text-sm whitespace-nowrap font-display font-semibold">
{{ item.label }}
</p>
<p
class="truncate text-[10px] font-bold uppercase font-display"
:class="[
getGameStatusStyleText(games[item.id].status.value)[0],
]"
>
{{ getGameStatusStyleText(games[item.id].status.value)[1] }}
</p>
</div>
</div>
</NuxtLink>
<span v-if="nav.items.length == 0" class="text-xs text-zinc-500 mx-auto"
>No games in this category</span
>
</DisclosurePanel>
</Disclosure>
</TransitionGroup>
<div
v-if="loading"
class="h-full grow flex p-8 justify-center text-zinc-100"
>
<div role="status">
<svg
aria-hidden="true"
class="w-6 h-6 text-transparent animate-spin fill-zinc-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span class="sr-only">Loading...</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Disclosure, DisclosureButton, DisclosurePanel } from "@headlessui/vue";
import {
ArrowPathIcon,
MagnifyingGlassIcon,
MinusIcon,
PlusIcon,
} from "@heroicons/vue/20/solid";
import { invoke } from "@tauri-apps/api/core";
import {
type EmptyGameStatusEnum,
InstalledType,
type Collection as Collection,
type Game,
type GameStatus,
} from "~/types";
import { TransitionGroup } from "vue";
import { listen } from "@tauri-apps/api/event";
// Style information
const gameStatusTextStyle: { [key in EmptyGameStatusEnum]: string } = {
Downloading: "text-zinc-400",
Validating: "text-blue-300",
Running: "text-blue-500",
Remote: "text-zinc-700",
Queued: "text-zinc-400",
Updating: "text-zinc-400",
Uninstalling: "text-zinc-100",
};
const gameStatusText: { [key in EmptyGameStatusEnum]: string } = {
Remote: "Not installed",
Queued: "Queued",
Downloading: "Downloading...",
Validating: "Validating...",
Updating: "Updating...",
Uninstalling: "Uninstalling...",
Running: "Running",
};
function getGameStatusStyleText(status: GameStatus): [string, string] {
if (status.type === "Installed") {
if (status.install_type.type === InstalledType.Installed) {
return ["text-green-500", "Installed"];
}
if (status.install_type.type === InstalledType.PartiallyInstalled) {
return ["text-gray-400", "Partially installed"];
}
if (status.install_type.type === InstalledType.SetupRequired) {
return ["text-yellow-500", "Setup required"];
}
throw (
"Non-exhaustive installed type, missing: " +
JSON.stringify(status.install_type)
);
}
return [gameStatusTextStyle[status.type], gameStatusText[status.type]];
}
const router = useRouter();
const searchQuery = ref("");
const loading = ref(false);
const games: {
[key: string]: { game: Game; status: Ref<GameStatus, GameStatus> };
} = {};
const collections: Ref<Collection[]> = ref([]);
async function calculateGames(clearAll = false, forceRefresh = false) {
try {
await calculateGamesLogic(clearAll, forceRefresh);
} catch (e) {
createModal(
ModalType.Notification,
{
title: "Failed to fetch library",
description: `Drop encountered an error while fetching your library: ${e}`,
},
(_, c) => c(),
);
}
loading.value = false;
}
type FetchLibraryResponse = {
library: Game[];
collections: Collection[];
other: Game[];
missing: Game[];
};
async function calculateGamesLogic(clearAll = false, forceRefresh = false) {
if (clearAll) {
collections.value = [];
loading.value = true;
}
// If we update immediately, the navigation gets re-rendered before we
// add all the necessary state, and it freaks tf out
const library = await invoke<FetchLibraryResponse>("fetch_library", {
hardRefresh: forceRefresh,
});
const allGames = [
...library.library,
...library.collections
.map((e) => e.entries)
.flat()
.map((e) => e.game),
...library.other,
...library.missing,
].filter((v, i, a) => a.indexOf(v) === i);
for (const game of allGames) {
if (games[game.id]) continue;
games[game.id] = await useGame(game.id);
}
const libraryCollection = {
id: "library",
name: "Library",
isDefault: true,
entries: library.library.map((e) => ({ gameId: e.id, game: e })),
} satisfies Collection;
const otherCollection = {
id: "other",
name: "Tools & Launchers",
isDefault: false,
isTools: true,
entries: library.other.map((v) => ({ gameId: v.id, game: v })),
} satisfies Collection;
const missingCollection = {
id: "missing",
name: "Delisted",
isDefault: false,
isTools: true,
entries: library.missing.map((v) => ({ gameId: v.id, game: v })),
};
loading.value = false;
collections.value = [
libraryCollection,
...library.collections,
...(library.other.length > 0 ? [otherCollection] : []),
...(library.missing.length > 0 ? [missingCollection] : []),
];
}
// Wait up to 300 ms for the library to load, otherwise
// show the loading state while we while
await new Promise<void>((r) => {
let hasResolved = false;
const resolveFunc = () => {
if (!hasResolved) r();
hasResolved = true;
};
calculateGames(true).then(resolveFunc);
setTimeout(resolveFunc, 300);
});
const navigation = computed(() =>
collections.value.map((collection) => {
const items = collection.entries.map(({ game }) => {
const status = games[game.id].status;
const isInstalled = computed(() => status.value.type != "Remote");
const item = {
label: game.mName,
route: `/library/${game.id}`,
prefix: `/library/${game.id}`,
icon: game.mIconObjectId,
isInstalled,
id: game.id,
type: game.type,
};
return item;
});
return {
id: collection.id,
name: collection.name,
deft: collection.isDefault,
tools: collection.isTools ?? false,
items,
};
}),
);
const route = useRoute();
const currentNavigation = computed(() => {
return route.path.slice("/library/".length);
});
const filteredNavigation = computed(() => {
if (!searchQuery.value)
return navigation.value.map((e, i) => ({ ...e, index: i }));
const query = searchQuery.value.toLowerCase();
return navigation.value
.map((c) => ({
...c,
items: c.items.filter((nav) => nav.label.toLowerCase().includes(query)),
}))
.filter((e) => e.items.length > 0);
});
listen("update_library", async (event) => {
console.log("Updating library");
let oldNavigation = currentNavigation.value;
await calculateGames(false, true);
if (oldNavigation !== currentNavigation.value) {
router.push("/library");
}
});
</script>
<style scoped>
.list-move,
.list-enter-active,
.list-leave-active {
transition: all 0.3s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(-30px);
}
.list-leave-active {
position: absolute;
}
</style>

View file

@ -0,0 +1,7 @@
<template>
<svg class="text-blue-400" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M4 13.5C4 11.0008 5.38798 8.76189 7.00766 7C8.43926 5.44272 10.0519 4.25811 11.0471 3.5959C11.6287 3.20893 12.3713 3.20893 12.9529 3.5959C13.9481 4.25811 15.5607 5.44272 16.9923 7C18.612 8.76189 20 11.0008 20 13.5C20 17.9183 16.4183 21.5 12 21.5C7.58172 21.5 4 17.9183 4 13.5Z"
stroke="currentColor" stroke-width="2" />
</svg>
</template>

View file

@ -0,0 +1,16 @@
<template>
<div
class="h-16 cursor-pointer flex flex-row items-center justify-between bg-zinc-950"
>
<div class="px-5 py-3 grow" @mousedown="() => window.startDragging()">
<Wordmark class="mt-1" />
</div>
<WindowControl />
</div>
</template>
<script setup lang="ts">
import { getCurrentWindow } from "@tauri-apps/api/window";
const window = getCurrentWindow();
</script>

View file

@ -0,0 +1,23 @@
<script setup lang="ts">
import { ArrowDownTrayIcon, CloudIcon } from "@heroicons/vue/20/solid";
import { invoke } from "@tauri-apps/api/core";
async function checkOffline() {
const isOffline = await invoke("check_online");
}
</script>
<template>
<button
@click="checkOffline"
class="transition inline-flex items-center rounded-sm px-4 py-1.5 bg-zinc-900 text-sm text-zinc-400 gap-x-2"
>
<div class="relative">
<CloudIcon class="h-5 z-50 text-zinc-500" />
<div
class="absolute rounded-full left-1/2 top-1/2 -translate-y-[45%] -translate-x-1/2 w-[2px] h-6 rotate-[45deg] bg-zinc-400 z-50"
/>
</div>
Offline
</button>
</template>

View file

@ -0,0 +1,7 @@
<template>
<NuxtLink
class="inline-flex items-center gap-x-2 px-1 py-0.5 rounded bg-blue-900 text-zinc-100 hover:bg-blue-800"
>
<slot />
</NuxtLink>
</template>

View file

@ -0,0 +1,24 @@
<template>
<HeaderButton v-if="showMinimise" @click="() => minimise()">
<MinusIcon />
</HeaderButton>
<HeaderButton @click="() => close()">
<XMarkIcon />
</HeaderButton>
</template>
<script setup lang="ts">
import { MinusIcon, XMarkIcon } from "@heroicons/vue/16/solid";
import { getCurrentWindow } from "@tauri-apps/api/window";
const window = getCurrentWindow();
const showMinimise = await window.isMinimizable();
async function close() {
await window.close();
}
async function minimise() {
await window.minimize();
}
</script>

View file

@ -0,0 +1,11 @@
<template>
<div class="inline-flex justify-center items-center gap-x-1 -mb-1 relative">
<svg aria-hidden="true" viewBox="0 0 418 42" class="absolute inset-0 h-full w-full fill-blue-300/30 scale-75"
preserveAspectRatio="none">
<path
d="M203.371.916c-26.013-2.078-76.686 1.963-124.73 9.946L67.3 12.749C35.421 18.062 18.2 21.766 6.004 25.934 1.244 27.561.828 27.778.874 28.61c.07 1.214.828 1.121 9.595-1.176 9.072-2.377 17.15-3.92 39.246-7.496C123.565 7.986 157.869 4.492 195.942 5.046c7.461.108 19.25 1.696 19.17 2.582-.107 1.183-7.874 4.31-25.75 10.366-21.992 7.45-35.43 12.534-36.701 13.884-2.173 2.308-.202 4.407 4.442 4.734 2.654.187 3.263.157 15.593-.78 35.401-2.686 57.944-3.488 88.365-3.143 46.327.526 75.721 2.23 130.788 7.584 19.787 1.924 20.814 1.98 24.557 1.332l.066-.011c1.201-.203 1.53-1.825.399-2.335-2.911-1.31-4.893-1.604-22.048-3.261-57.509-5.556-87.871-7.36-132.059-7.842-23.239-.254-33.617-.116-50.627.674-11.629.54-42.371 2.494-46.696 2.967-2.359.259 8.133-3.625 26.504-9.81 23.239-7.825 27.934-10.149 28.304-14.005.417-4.348-3.529-6-16.878-7.066Z" />
</svg>
<Logo class="h-6" />
<span class="text-blue-400 font-display font-bold text-xl uppercase">Drop</span>
</div>
</template>

View file

@ -0,0 +1,51 @@
import { invoke } from "@tauri-apps/api/core";
// Thin wrappers over the generic Rust bridge (community_api.rs): the client
// passes the server's own `/api/v1/...` path, Rust resolves the base URL +
// signs the request with the same auth every other client call uses. See
// desktop/src-tauri/src/community_api.rs for why this is generic rather than
// one typed Tauri command per endpoint.
//
// On a non-2xx response the Rust side rejects with a stringified
// RemoteAccessError::InvalidResponse whose message is the server's own
// `statusMessage`/`message` -- callers can show `(e as string)` directly.
export function apiGet<T = any>(
path: string,
query?: Record<string, string | number | boolean | undefined>,
): Promise<T> {
const q = query
? Object.entries(query)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => [k, String(v)] as [string, string])
: undefined;
return invoke<T>("api_get", { path, query: q });
}
// Same as apiGet, but for `/api/v1/client/*` routes built on
// `defineClientEventHandler` (news, library, game manifests, ...). Those
// routes authenticate with the desktop client's own short-lived JWT
// (Rust's generate_authorization_header), NOT the aclManager webtoken
// apiGet's api_get command mints -- sending them a webtoken 403s no matter
// what ACLs it carries, since defineClientEventHandler never looks at
// ACLs at all. See desktop/src-tauri/src/community_api.rs's module header
// for the full explanation of why there are two auth paths here.
export function apiGetClient<T = any>(
path: string,
query?: Record<string, string | number | boolean | undefined>,
): Promise<T> {
const q = query
? Object.entries(query)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => [k, String(v)] as [string, string])
: undefined;
return invoke<T>("api_get_jwt", { path, query: q });
}
export function apiPost<T = any>(path: string, body?: unknown): Promise<T> {
return invoke<T>("api_post", { path, body: body ?? {} });
}
export function apiDelete<T = any>(path: string): Promise<T> {
return invoke<T>("api_delete", { path });
}

View file

@ -0,0 +1,3 @@
import type { AppState } from "~/types";
export const useAppState = () => useState<AppState | undefined>("state");

View file

@ -0,0 +1,169 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
// Frontend half of the community websocket bridge
// (desktop/src-tauri/src/community_ws.rs). One shared connection for the
// whole app (chat rooms, live chat.message frames, presence updates,
// friend-request pushes), matching chatService's `{t, d}` envelope exactly
// (server/server/api/v1/community/ws.get.ts).
//
// `useState` makes this a Nuxt-style singleton: every component that calls
// useCommunityWs() shares the same reactive state and the same single
// underlying socket, rather than each page opening its own connection.
export interface ChatMessageDTO {
id: string;
roomId: string;
kind: "text" | "system";
body: string;
deleted: boolean;
clientNonce: string | null;
replyToId: string | null;
sender: {
id: string;
username: string;
displayName: string;
avatarUrl: string | null;
} | null;
createdAt: string;
editedAt: string | null;
}
interface PresenceUpdate {
userId: string;
state: "online" | "offline";
}
interface FriendRequestPush {
status: "pending" | "accepted" | "declined";
userId: string;
}
type WsListener = (envelope: { t: string; d: any }) => void;
interface CommunityWsInternal {
connected: boolean;
connecting: boolean;
subscribedTopics: Set<string>;
listeners: Set<WsListener>;
initialized: boolean;
}
// Not part of the reactive useState -- a Set doesn't play well with Vue's
// deep reactivity/serialization and none of this needs to trigger renders
// directly (callers derive their own reactive state from message events).
const internal: CommunityWsInternal = {
connected: false,
connecting: false,
subscribedTopics: new Set(),
listeners: new Set(),
initialized: false,
};
const wsConnected = () => useState<boolean>("community-ws-connected", () => false);
async function ensureConnected() {
if (internal.connected || internal.connecting) return;
internal.connecting = true;
try {
await invoke("community_ws_connect");
} catch (e) {
console.error("community ws: connect failed", e);
internal.connecting = false;
return;
}
internal.connecting = false;
}
function initOnce() {
if (internal.initialized) return;
internal.initialized = true;
listen<string>("community/ws-message", (event) => {
let envelope: { t: string; d: any };
try {
envelope = JSON.parse(event.payload);
} catch {
return;
}
if (envelope.t === "pong" || envelope.t === "notice") {
if (envelope.t === "notice") console.warn("community ws notice:", envelope.d);
return;
}
for (const listener of internal.listeners) listener(envelope);
});
listen("community/ws-closed", () => {
internal.connected = false;
wsConnected().value = false;
internal.subscribedTopics.clear();
// Reconnect with a short, fixed backoff -- chat is supposed to feel
// live, so retry promptly rather than leaving the user silently
// disconnected until they navigate away and back.
setTimeout(() => {
ensureConnected();
}, 3000);
});
// A connect can race a page mount; mark connected optimistically once the
// invoke resolves (community_ws_connect only returns Ok after the upgrade
// handshake succeeds).
}
export function useCommunityWs() {
initOnce();
async function connect() {
await ensureConnected();
internal.connected = true;
wsConnected().value = true;
}
function send(t: string, d: unknown = {}) {
invoke("community_ws_send", { payload: JSON.stringify({ t, d }) }).catch((e) => {
console.error("community ws: send failed", e);
});
}
function subscribe(topics: string[]) {
const fresh = topics.filter((t) => !internal.subscribedTopics.has(t));
if (fresh.length === 0) return;
for (const t of fresh) internal.subscribedTopics.add(t);
send("sub", { topics: fresh });
}
function unsubscribe(topics: string[]) {
for (const t of topics) internal.subscribedTopics.delete(t);
send("unsub", { topics });
}
function onMessage(listener: WsListener) {
internal.listeners.add(listener);
return () => internal.listeners.delete(listener);
}
function sendChatMessage(roomId: string, body: string, clientNonce?: string) {
send("chat.send", { roomId, body, clientNonce });
}
function sendTyping(roomId: string) {
send("chat.typing", { roomId });
}
function sendRead(roomId: string, lastReadMessageId: string) {
send("chat.read", { roomId, lastReadMessageId });
}
return {
connected: wsConnected(),
connect,
subscribe,
unsubscribe,
onMessage,
sendChatMessage,
sendTyping,
sendRead,
};
}
export type { PresenceUpdate, FriendRequestPush };

View file

@ -0,0 +1,32 @@
import type { RouteLocationNormalized } from "vue-router";
import type { NavigationItem } from "~/types";
export const useCurrentNavigationIndex = (
navigation: Array<NavigationItem>
) => {
const router = useRouter();
const route = useRoute();
const currentNavigation = ref(-1);
function calculateCurrentNavIndex(to: RouteLocationNormalized) {
const validOptions = navigation
.map((e, i) => ({ ...e, index: i }))
.filter((e) => to.fullPath.startsWith(e.prefix));
const bestOption = validOptions
.sort((a, b) => b.route.length - a.route.length)
.at(0);
return bestOption?.index ?? -1;
}
currentNavigation.value = calculateCurrentNavIndex(route);
router.afterEach((to) => {
currentNavigation.value = calculateCurrentNavIndex(to);
});
return {currentNavigation, recalculateNavigation: () => {
currentNavigation.value = calculateCurrentNavIndex(route);
}};
};

Some files were not shown because too many files have changed in this diff Show more