Compare commits

..

2 Commits

Author SHA1 Message Date
Jai Agrawal
d841484b2c Merge branch 'main' into chore/posthog-ingestion 2025-04-28 13:27:23 -07:00
Michael H.
c8c60667f6 Update PostHog ingestion key 2025-04-25 23:15:09 +02:00
685 changed files with 16140 additions and 21070 deletions

View File

@@ -1,6 +1,6 @@
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler # Windows has stack overflows when calling from Tauri, so we increase compiler size
[target.'cfg(windows)'] [target.'cfg(windows)']
rustflags = ["-C", "link-args=/STACK:16777220"] rustflags = ["-C", "link-args=/STACK:16777220"]
[build] [build]
rustflags = ["--cfg", "tokio_unstable"] rustflags = ["--cfg", "tokio_unstable"]

25
.github/CODEOWNERS vendored
View File

@@ -1,25 +0,0 @@
/apps/frontend/ @modrinth/frontend
/apps/app-frontend/ @modrinth/frontend
/apps/daedalus_client/ @modrinth/backend
/apps/docs/ @modrinth/support @modrinth/platform @modrinth/servers
/apps/labrinth @modrinth/backend
/apps/app @modrinth/backend
/packages/app-lib/ @modrinth/backend
/packages/ariadne/ @modrinth/backend
/packages/assets/ @modrinth/frontend
/packages/daedalus/ @modrinth/backend
/packages/eslint-config-custom/ @modrinth/frontend
/packages/tsconfig/ @modrinth/frontend
/packages/ui/ @modrinth/frontend
/packages/utils/ @modrinth/frontend
README.md @modrinth/support
LICENSE @Geometrically @Prospector
/COPYING.md @Geometrically @Prospector
/apps/frontend/src/pages/legal/ @Geometrically @Prospector
/.github/ @Geometrically @Prospector
/docker-compose.yml @modrinth/backend

View File

@@ -6,7 +6,7 @@ body:
attributes: attributes:
label: Please confirm the following. label: Please confirm the following.
options: options:
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate problems - label: I checked the [existing issues](https://github.com/modrinth/code/issues) for duplicate problems
required: true required: true
- label: I have tried resolving the issue using the [support portal](https://support.modrinth.com) - label: I have tried resolving the issue using the [support portal](https://support.modrinth.com)
required: true required: true

View File

@@ -6,7 +6,7 @@ body:
attributes: attributes:
label: Please confirm the following. label: Please confirm the following.
options: options:
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate problems - label: I checked the [existing issues](https://github.com/modrinth/code/issues) for duplicate problems
required: true required: true
- label: I have tried resolving the issue using the [support portal](https://support.modrinth.com) - label: I have tried resolving the issue using the [support portal](https://support.modrinth.com)
required: true required: true

View File

@@ -6,7 +6,7 @@ body:
attributes: attributes:
label: Please confirm the following. label: Please confirm the following.
options: options:
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate problems - label: I checked the [existing issues](https://github.com/modrinth/code/issues) for duplicate problems
required: true required: true
- label: I have tried resolving the issue using the [support portal](https://support.modrinth.com) - label: I have tried resolving the issue using the [support portal](https://support.modrinth.com)
required: true required: true

View File

@@ -7,7 +7,7 @@ body:
attributes: attributes:
label: Please confirm the following. label: Please confirm the following.
options: options:
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate feature requests - label: I checked the [existing issues](https://github.com/modrinth/code/issues) for duplicate feature requests
required: true required: true
- label: I have checked that this feature request is not on our [roadmap](https://roadmap.modrinth.com) - label: I have checked that this feature request is not on our [roadmap](https://roadmap.modrinth.com)
required: true required: true

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -32,16 +32,16 @@ jobs:
- name: Rust setup (mac) - name: Rust setup (mac)
if: startsWith(matrix.platform, 'macos') if: startsWith(matrix.platform, 'macos')
uses: actions-rust-lang/setup-rust-toolchain@v1 uses: dtolnay/rust-toolchain@stable
with: with:
rustflags: '' components: rustfmt, clippy
target: x86_64-apple-darwin targets: aarch64-apple-darwin, x86_64-apple-darwin
- name: Rust setup - name: Rust setup
if: "!startsWith(matrix.platform, 'macos')" if: "!startsWith(matrix.platform, 'macos')"
uses: actions-rust-lang/setup-rust-toolchain@v1 uses: dtolnay/rust-toolchain@stable
with: with:
rustflags: '' components: rustfmt, clippy
- name: Setup rust cache - name: Setup rust cache
uses: actions/cache@v4 uses: actions/cache@v4
@@ -72,10 +72,10 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-rust-target- ${{ runner.os }}-rust-target-
- name: Setup Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version-file: .nvmrc node-version: 20
- name: Install pnpm via corepack - name: Install pnpm via corepack
shell: bash shell: bash

View File

@@ -2,7 +2,7 @@ name: CI
on: on:
push: push:
branches: [main] branches: ['main']
pull_request: pull_request:
types: [opened, synchronize] types: [opened, synchronize]
merge_group: merge_group:
@@ -10,69 +10,71 @@ on:
jobs: jobs:
build: build:
name: Lint and Test name: Build, Test, and Lint
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
env:
# Ensure pnpm output is colored in GitHub Actions logs
FORCE_COLOR: 3
# Make cargo nextest successfully ignore projects without tests
NEXTEST_NO_TESTS: pass
steps: steps:
- name: 📥 Check out code - name: Check out code
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 2 fetch-depth: 2
- name: 🧰 Install build dependencies - name: Cache turbo build setup
uses: actions/cache@v4
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-
- name: Install build dependencies
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -yq libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: 🧰 Install pnpm - name: Setup Node.JS environment
uses: pnpm/action-setup@v4
- name: 🧰 Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version-file: .nvmrc node-version: 20
cache: pnpm
- name: 🧰 Setup Rust toolchain - name: Install pnpm via corepack
uses: actions-rust-lang/setup-rust-toolchain@v1 shell: bash
run: |
corepack enable
corepack prepare --activate
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
name: Setup pnpm cache
with: with:
rustflags: '' path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
components: clippy, rustfmt key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
cache: false restore-keys: |
${{ runner.os }}-pnpm-store-
- name: 🧰 Setup nextest - name: Install dependencies
uses: taiki-e/install-action@nextest
# cargo-binstall does not have pre-built binaries for sqlx-cli, so we fall
# back to a cached cargo install
- name: 🧰 Setup cargo-sqlx
uses: AlexTMjugador/cache-cargo-install-action@feat/features-support
with:
tool: sqlx-cli
locked: false
no-default-features: true
features: rustls,postgres
- name: 💨 Setup Turbo cache
uses: rharkor/caching-for-turbo@v1.8
- name: 🧰 Install dependencies
run: pnpm install run: pnpm install
- name: ⚙️ Start services - name: Build
run: docker compose up --wait run: pnpm build
env:
SQLX_OFFLINE: true
- name: ⚙️ Setup Labrinth environment and database - name: Lint
working-directory: apps/labrinth run: pnpm lint
run: | env:
cp .env.local .env SQLX_OFFLINE: true
sqlx database setup
- name: 🔍 Lint and test - name: Start docker compose
run: pnpm run ci run: docker compose up -d
- name: Test
run: pnpm test
env:
SQLX_OFFLINE: true
DATABASE_URL: postgresql://labrinth:labrinth@localhost/postgres

6
.idea/vcs.xml generated
View File

@@ -1,11 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="CommitMessageInspectionProfile">
<profile version="1.0">
<inspection_tool class="CommitFormat" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="CommitNamingConvention" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" /> <mapping directory="" vcs="Git" />
</component> </component>

1
.nvmrc
View File

@@ -1 +0,0 @@
20.19.2

View File

@@ -2,7 +2,6 @@
"prettier.endOfLine": "lf", "prettier.endOfLine": "lf",
"editor.formatOnSave": true, "editor.formatOnSave": true,
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
"editor.detectIndentation": true,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit" "source.fixAll.eslint": "explicit"
} }

5128
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,226 +1,25 @@
[workspace] [workspace]
resolver = "2" resolver = '2'
members = [ members = [
"apps/app", './packages/app-lib',
"apps/app-playground", './apps/app-playground',
"apps/daedalus_client", './apps/app',
"apps/labrinth", './apps/labrinth',
"packages/app-lib", './apps/daedalus_client',
"packages/ariadne", './packages/daedalus',
"packages/daedalus", './packages/ariadne',
] ]
[workspace.package]
edition = "2024"
[workspace.dependencies]
actix-cors = "0.7.1"
actix-files = "0.6.6"
actix-http = "3.11.0"
actix-multipart = "0.7.2"
actix-rt = "2.10.0"
actix-web = "4.11.0"
actix-web-prom = "0.10.0"
actix-ws = "0.3.0"
argon2 = { version = "0.5.3", features = ["std"] }
ariadne = { path = "packages/ariadne" }
async_zip = "0.0.17"
async-compression = { version = "0.4.24", default-features = false }
async-recursion = "1.1.1"
async-stripe = { version = "0.41.0", default-features = false, features = [
"runtime-tokio-hyper-rustls",
] }
async-trait = "0.1.88"
async-tungstenite = { version = "0.29.1", default-features = false, features = [
"futures-03-sink",
] }
async-walkdir = "2.1.0"
base64 = "0.22.1"
bitflags = "2.9.1"
bytes = "1.10.1"
censor = "0.3.0"
chardetng = "0.1.17"
chrono = "0.4.41"
clap = "4.5.40"
clickhouse = "0.13.3"
color-thief = "0.2.2"
console-subscriber = "0.4.1"
daedalus = { path = "packages/daedalus" }
dashmap = "6.1.0"
deadpool-redis = "0.21.1"
dirs = "6.0.0"
discord-rich-presence = "0.2.5"
dotenv-build = "0.1.1"
dotenvy = "0.15.7"
dunce = "1.0.5"
either = "1.15.0"
encoding_rs = "0.8.35"
enumset = "1.1.6"
flate2 = "1.1.2"
fs4 = { version = "0.13.1", default-features = false }
futures = { version = "0.3.31", default-features = false }
futures-util = "0.3.31"
hex = "0.4.3"
hickory-resolver = "0.25.2"
hmac = "0.12.1"
hyper-tls = "0.6.0"
hyper-util = "0.1.14"
iana-time-zone = "0.1.63"
image = { version = "0.25.6", default-features = false, features = ["rayon"] }
indexmap = "2.9.0"
indicatif = "0.17.11"
itertools = "0.14.0"
jemalloc_pprof = "0.7.0"
json-patch = { version = "4.0.0", default-features = false }
lettre = { version = "0.11.17", default-features = false, features = [
"builder",
"hostname",
"pool",
"ring",
"rustls",
"rustls-native-certs",
"smtp-transport",
] }
maxminddb = "0.26.0"
meilisearch-sdk = { version = "0.28.0", default-features = false }
murmur2 = "0.1.0"
native-dialog = "0.9.0"
notify = { version = "8.0.0", default-features = false }
notify-debouncer-mini = { version = "0.6.0", default-features = false }
p256 = "0.13.2"
paste = "1.0.15"
prometheus = "0.14.0"
quartz_nbt = "0.2.9"
quick-xml = "0.37.5"
rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
redis = "=0.31.0" # Locked on 0.31 until deadpool-redis updates to 0.32
regex = "1.11.1"
reqwest = { version = "0.12.19", default-features = false }
rust_decimal = { version = "1.37.1", features = [
"serde-with-float",
"serde-with-str",
] }
rust_iso3166 = "0.1.14"
rust-s3 = { version = "0.35.1", default-features = false, features = [
"fail-on-err",
"tags",
"tokio-rustls-tls",
] }
rusty-money = "0.4.1"
sentry = { version = "0.38.1", default-features = false, features = [
"backtrace",
"contexts",
"debug-images",
"panic",
"reqwest",
"rustls",
] }
sentry-actix = "0.38.1"
serde = "1.0.219"
serde_bytes = "0.11.17"
serde_cbor = "0.11.2"
serde_ini = "0.2.0"
serde_json = "1.0.140"
serde_with = "3.12.0"
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
sha1 = "0.10.6"
sha1_smol = { version = "1.0.1", features = ["std"] }
sha2 = "0.10.9"
spdx = "0.10.8"
sqlx = { version = "0.8.6", default-features = false }
sysinfo = { version = "0.35.2", default-features = false }
tar = "0.4.44"
tauri = "2.5.1"
tauri-build = "2.2.0"
tauri-plugin-deep-link = "2.3.0"
tauri-plugin-dialog = "2.2.2"
tauri-plugin-opener = "2.2.7"
tauri-plugin-os = "2.2.1"
tauri-plugin-single-instance = "2.2.4"
tauri-plugin-updater = { version = "2.7.1", default-features = false, features = [
"rustls-tls",
"zip",
] }
tauri-plugin-window-state = "2.2.2"
tempfile = "3.20.0"
theseus = { path = "packages/app-lib" }
thiserror = "2.0.12"
tikv-jemalloc-ctl = "0.6.0"
tikv-jemallocator = "0.6.0"
tokio = "1.45.1"
tokio-stream = "0.1.17"
tokio-util = "0.7.15"
totp-rs = "5.7.0"
tracing = "0.1.41"
tracing-actix-web = "0.7.18"
tracing-error = "0.2.1"
tracing-subscriber = "0.3.19"
url = "2.5.4"
urlencoding = "2.1.3"
uuid = "1.17.0"
validator = "0.20.0"
webp = { version = "0.3.0", default-features = false }
whoami = "1.6.0"
winreg = "0.55.0"
woothee = "0.13.0"
yaserde = "0.12.0"
zip = { version = "4.0.0", default-features = false, features = [
"bzip2",
"deflate",
"deflate64",
"zstd",
] }
zxcvbn = "3.1.0"
[workspace.lints.clippy]
bool_to_int_with_if = "warn"
borrow_as_ptr = "warn"
cfg_not_test = "warn"
clear_with_drain = "warn"
cloned_instead_of_copied = "warn"
collection_is_never_read = "warn"
dbg_macro = "warn"
default_trait_access = "warn"
explicit_iter_loop = "warn"
filter_map_next = "warn"
flat_map_option = "warn"
format_push_string = "warn"
get_unwrap = "warn"
large_include_file = "warn"
large_stack_arrays = "warn"
manual_assert = "warn"
manual_instant_elapsed = "warn"
manual_is_variant_and = "warn"
manual_let_else = "warn"
map_unwrap_or = "warn"
match_bool = "warn"
needless_collect = "warn"
negative_feature_names = "warn"
non_std_lazy_statics = "warn"
pathbuf_init_then_push = "warn"
read_zero_byte_vec = "warn"
redundant_clone = "warn"
redundant_feature_names = "warn"
redundant_type_annotations = "warn"
todo = "warn"
unnested_or_patterns = "warn"
wildcard_dependencies = "warn"
[workspace.lints.rust]
# Turn warnings into errors by default
warnings = "deny"
[patch.crates-io]
wry = { git = "https://github.com/modrinth/wry", rev = "cafdaa9" }
# Optimize for speed and reduce size on release builds # Optimize for speed and reduce size on release builds
[profile.release] [profile.release]
opt-level = "s" # Optimize for binary size panic = "abort" # Strip expensive panic clean-up logic
strip = true # Remove debug symbols codegen-units = 1 # Compile crates one after another so the compiler can optimize better
lto = true # Enables link to optimizations lto = true # Enables link to optimizations
panic = "abort" # Strip expensive panic clean-up logic opt-level = "s" # Optimize for binary size
codegen-units = 1 # Compile crates one after another so the compiler can optimize better strip = true # Remove debug symbols
[profile.dev.package.sqlx-macros] [profile.dev.package.sqlx-macros]
opt-level = 3 opt-level = 3
[patch.crates-io]
wry = { git = "https://github.com/modrinth/wry", rev = "51907c6" }

View File

@@ -1,7 +1,7 @@
{ {
"name": "@modrinth/app-frontend", "name": "@modrinth/app-frontend",
"private": true, "private": true,
"version": "0.9.5", "version": "0.9.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -9,21 +9,20 @@
"tsc:check": "vue-tsc --noEmit", "tsc:check": "vue-tsc --noEmit",
"lint": "eslint . && prettier --check .", "lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .", "fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace", "intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace"
"test": "vue-tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@geometrically/minecraft-motd-parser": "^1.1.4",
"@modrinth/assets": "workspace:*", "@modrinth/assets": "workspace:*",
"@modrinth/ui": "workspace:*", "@modrinth/ui": "workspace:*",
"@modrinth/utils": "workspace:*", "@modrinth/utils": "workspace:*",
"@sentry/vue": "^8.27.0", "@sentry/vue": "^8.27.0",
"@tauri-apps/api": "^2.5.0", "@geometrically/minecraft-motd-parser": "^1.1.4",
"@tauri-apps/plugin-dialog": "^2.2.1", "@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-opener": "^2.2.6", "@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-os": "^2.2.1", "@tauri-apps/plugin-os": "^2.2.0",
"@tauri-apps/plugin-updater": "^2.7.1", "@tauri-apps/plugin-opener": "^2.2.1",
"@tauri-apps/plugin-window-state": "^2.2.2", "@tauri-apps/plugin-updater": "^2.3.0",
"@tauri-apps/plugin-window-state": "^2.2.0",
"@vintl/vintl": "^4.4.1", "@vintl/vintl": "^4.4.1",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"floating-vue": "^5.2.2", "floating-vue": "^5.2.2",
@@ -40,12 +39,11 @@
"@eslint/compat": "^1.1.1", "@eslint/compat": "^1.1.1",
"@formatjs/cli": "^6.2.12", "@formatjs/cli": "^6.2.12",
"@nuxt/eslint-config": "^0.5.6", "@nuxt/eslint-config": "^0.5.6",
"@taijased/vue-render-tracker": "^1.0.7",
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.4",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"eslint": "^9.9.1", "eslint": "^9.9.1",
"eslint-config-custom": "workspace:*", "eslint-config-custom": "workspace:*",
"eslint-plugin-turbo": "^2.5.4", "eslint-plugin-turbo": "^2.1.1",
"postcss": "^8.4.39", "postcss": "^8.4.39",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"sass": "^1.74.1", "sass": "^1.74.1",
@@ -53,7 +51,8 @@
"tsconfig": "workspace:*", "tsconfig": "workspace:*",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"vite": "^5.4.6", "vite": "^5.4.6",
"vue-tsc": "^2.1.6" "vue-tsc": "^2.1.6",
"@taijased/vue-render-tracker": "^1.0.7"
}, },
"packageManager": "pnpm@9.4.0", "packageManager": "pnpm@9.4.0",
"web-types": "../../web-types.json" "web-types": "../../web-types.json"

View File

@@ -19,19 +19,12 @@ import {
WorldIcon, WorldIcon,
XIcon, XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { import { Avatar, Button, ButtonStyled, Notifications, OverflowMenu } from '@modrinth/ui'
Avatar,
Button,
ButtonStyled,
Notifications,
OverflowMenu,
useRelativeTime,
} from '@modrinth/ui'
import { useLoading, useTheming } from '@/store/state' import { useLoading, useTheming } from '@/store/state'
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component' import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
import AccountsCard from '@/components/ui/AccountsCard.vue' import AccountsCard from '@/components/ui/AccountsCard.vue'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue' import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import { get } from '@/helpers/settings.ts' import { get } from '@/helpers/settings'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue' import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import RunningAppBar from '@/components/ui/RunningAppBar.vue' import RunningAppBar from '@/components/ui/RunningAppBar.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue' import SplashScreen from '@/components/ui/SplashScreen.vue'
@@ -69,8 +62,6 @@ import FriendsList from '@/components/ui/friends/FriendsList.vue'
import { openUrl } from '@tauri-apps/plugin-opener' import { openUrl } from '@tauri-apps/plugin-opener'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue' import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
const formatRelativeTime = useRelativeTime()
const themeStore = useTheming() const themeStore = useTheming()
const news = ref([]) const news = ref([])
@@ -599,7 +590,7 @@ function handleAuxClick(e) {
</h4> </h4>
<p class="my-1 text-sm text-secondary leading-tight">{{ item.summary }}</p> <p class="my-1 text-sm text-secondary leading-tight">{{ item.summary }}</p>
<p class="text-right text-sm text-secondary opacity-60 leading-tight m-0"> <p class="text-right text-sm text-secondary opacity-60 leading-tight m-0">
{{ formatRelativeTime(dayjs(item.date).toISOString()) }} {{ dayjs(item.date).fromNow() }}
</p> </p>
</a> </a>
<hr <hr

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 937 KiB

View File

@@ -7,7 +7,7 @@
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
font-weight: 400; font-weight: 400;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/regular.otf') format('opentype'); src: url('https://cdn.modrinth.com/fonts/minecraft/regular.otf') format('opentype');
} }
@font-face { @font-face {
@@ -15,7 +15,7 @@
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
font-weight: 400; font-weight: 400;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/italic.otf') format('opentype'); src: url('https://cdn.modrinth.com/fonts/minecraft/italic.otf') format('opentype');
} }
@font-face { @font-face {
@@ -23,7 +23,7 @@
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
font-weight: 600; font-weight: 600;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold.otf') format('opentype'); src: url('https://cdn.modrinth.com/fonts/minecraft/bold.otf') format('opentype');
} }
@font-face { @font-face {
@@ -31,7 +31,7 @@
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
font-weight: 600; font-weight: 600;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold-italic.otf') format('opentype'); src: url('https://cdn.modrinth.com/fonts/minecraft/bold-italic.otf') format('opentype');
} }
.font-minecraft { .font-minecraft {

View File

@@ -14,7 +14,7 @@ import { ref, computed } from 'vue'
import { login as login_flow, set_default_user } from '@/helpers/auth.js' import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { handleError } from '@/store/notifications.js' import { handleError } from '@/store/notifications.js'
import { handleSevereError } from '@/store/error.js' import { handleSevereError } from '@/store/error.js'
import { cancel_directory_change } from '@/helpers/settings.ts' import { cancel_directory_change } from '@/helpers/settings.js'
import { install } from '@/helpers/profile.js' import { install } from '@/helpers/profile.js'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'

View File

@@ -9,7 +9,7 @@ import {
StopCircleIcon, StopCircleIcon,
TimerIcon, TimerIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { Avatar, ButtonStyled, useRelativeTime } from '@modrinth/ui' import { Avatar, ButtonStyled } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
import { finish_install, kill, run } from '@/helpers/profile' import { finish_install, kill, run } from '@/helpers/profile'
import { get_by_profile_path } from '@/helpers/process' import { get_by_profile_path } from '@/helpers/process'
@@ -19,9 +19,10 @@ import { showProfileInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js' import { handleSevereError } from '@/store/error.js'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { formatCategory } from '@modrinth/utils' import { formatCategory } from '@modrinth/utils'
const formatRelativeTime = useRelativeTime() dayjs.extend(relativeTime)
const props = defineProps({ const props = defineProps({
instance: { instance: {
@@ -172,9 +173,7 @@ onUnmounted(() => unlisten())
</div> </div>
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold"> <div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
<TimerIcon /> <TimerIcon />
<span class="text-sm"> <span class="text-sm"> Played {{ dayjs(instance.last_played).fromNow() }} </span>
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
</span>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -1,5 +1,5 @@
<template> <template>
<ModalWrapper ref="modal" header="Creating an instance"> <ModalWrapper ref="modal" header="Create instance">
<div class="modal-header"> <div class="modal-header">
<Chips v-model="creationType" :items="['custom', 'from file', 'import from launcher']" /> <Chips v-model="creationType" :items="['custom', 'from file', 'import from launcher']" />
</div> </div>

View File

@@ -124,11 +124,8 @@ import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime' import relativeTime from 'dayjs/plugin/relativeTime'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { install as installVersion } from '@/store/install.js' import { install as installVersion } from '@/store/install.js'
import { useRouter } from 'vue-router'
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
const router = useRouter()
const props = defineProps({ const props = defineProps({
backgroundImage: { backgroundImage: {
type: String, type: String,
@@ -171,9 +168,6 @@ async function install() {
installing.value = false installing.value = false
emit('install', props.project.project_id ?? props.project.id) emit('install', props.project.project_id ?? props.project.id)
}, },
(profile) => {
router.push(`/instance/${profile}`)
},
) )
} }

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Avatar, ButtonStyled, OverflowMenu, useRelativeTime } from '@modrinth/ui' import { Avatar, ButtonStyled, OverflowMenu } from '@modrinth/ui'
import { import {
UserPlusIcon, UserPlusIcon,
MoreVerticalIcon, MoreVerticalIcon,
@@ -18,8 +18,6 @@ import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
const formatRelativeTime = useRelativeTime()
const props = defineProps<{ const props = defineProps<{
credentials: unknown | null credentials: unknown | null
signIn: () => void signIn: () => void
@@ -207,9 +205,7 @@ onUnmounted(() => {
You sent <span class="font-bold">{{ friend.username }}</span> a friend request You sent <span class="font-bold">{{ friend.username }}</span> a friend request
</template> </template>
</p> </p>
<p class="m-0 text-sm text-secondary"> <p class="m-0 text-sm text-secondary">{{ friend.created.fromNow() }}</p>
{{ formatRelativeTime(friend.created.toISOString()) }}
</p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<template v-if="friend.id === userCredentials.user_id"> <template v-if="friend.id === userCredentials.user_id">

View File

@@ -13,17 +13,15 @@ const confirmModal = ref(null)
const installing = ref(false) const installing = ref(false)
const onInstall = ref(() => {}) const onInstall = ref(() => {})
const onCreateInstance = ref(() => {})
defineExpose({ defineExpose({
show: (projectVal, versionIdVal, callback, createInstanceCallback) => { show: (projectVal, versionIdVal, callback) => {
project.value = projectVal project.value = projectVal
versionId.value = versionIdVal versionId.value = versionIdVal
installing.value = false installing.value = false
confirmModal.value.show() confirmModal.value.show()
onInstall.value = callback onInstall.value = callback
onCreateInstance.value = createInstanceCallback
trackEvent('PackInstallStart') trackEvent('PackInstallStart')
}, },
@@ -38,7 +36,6 @@ async function install() {
versionId.value, versionId.value,
project.value.title, project.value.title,
project.value.icon_url, project.value.icon_url,
onCreateInstance.value,
).catch(handleError) ).catch(handleError)
trackEvent('PackInstall', { trackEvent('PackInstall', {
id: project.value.id, id: project.value.id,

View File

@@ -3,7 +3,7 @@ import { Checkbox } from '@modrinth/ui'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { handleError } from '@/store/notifications' import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import { get } from '@/helpers/settings.ts' import { get } from '@/helpers/settings'
import { edit } from '@/helpers/profile' import { edit } from '@/helpers/profile'
import type { InstanceSettingsTabProps, AppSettings, Hooks } from '../../../helpers/types' import type { InstanceSettingsTabProps, AppSettings, Hooks } from '../../../helpers/types'
@@ -25,8 +25,9 @@ const editProfileObject = computed(() => {
hooks?: Hooks hooks?: Hooks
} = {} } = {}
// When hooks are not overridden per-instance, we want to clear them if (overrideHooks.value) {
editProfile.hooks = overrideHooks.value ? hooks.value : {} editProfile.hooks = hooks.value
}
return editProfile return editProfile
}) })

View File

@@ -7,7 +7,7 @@ import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import JavaSelector from '@/components/ui/JavaSelector.vue' import JavaSelector from '@/components/ui/JavaSelector.vue'
import { get_max_memory } from '@/helpers/jre' import { get_max_memory } from '@/helpers/jre'
import { get } from '@/helpers/settings.ts' import { get } from '@/helpers/settings'
import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types' import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()

View File

@@ -3,7 +3,7 @@ import { Checkbox, Toggle } from '@modrinth/ui'
import { computed, ref, type Ref, watch } from 'vue' import { computed, ref, type Ref, watch } from 'vue'
import { handleError } from '@/store/notifications' import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import { get } from '@/helpers/settings.ts' import { get } from '@/helpers/settings'
import { edit } from '@/helpers/profile' import { edit } from '@/helpers/profile'
import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types' import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types'

View File

@@ -22,7 +22,7 @@ import { version as getOsVersion, platform as getOsPlatform } from '@tauri-apps/
import { useTheming } from '@/store/state' import { useTheming } from '@/store/state'
import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue' import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get, set } from '@/helpers/settings.ts' import { get, set } from '@/helpers/settings'
const themeStore = useTheming() const themeStore = useTheming()

View File

@@ -2,7 +2,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { ConfirmModal } from '@modrinth/ui' import { ConfirmModal } from '@modrinth/ui'
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js' import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.ts' import { useTheming } from '@/store/theme.js'
const themeStore = useTheming() const themeStore = useTheming()

View File

@@ -2,7 +2,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { NewModal as Modal } from '@modrinth/ui' import { NewModal as Modal } from '@modrinth/ui'
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js' import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.ts' import { useTheming } from '@/store/theme.js'
const themeStore = useTheming() const themeStore = useTheming()

View File

@@ -2,7 +2,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { ShareModal } from '@modrinth/ui' import { ShareModal } from '@modrinth/ui'
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js' import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.ts' import { useTheming } from '@/store/theme.js'
const themeStore = useTheming() const themeStore = useTheming()

View File

@@ -1,10 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { TeleportDropdownMenu, ThemeSelector, Toggle } from '@modrinth/ui' import { TeleportDropdownMenu, ThemeSelector, Toggle } from '@modrinth/ui'
import { useTheming } from '@/store/state' import { useTheming } from '@/store/state'
import { get, set } from '@/helpers/settings.ts' import { get, set } from '@/helpers/settings'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { getOS } from '@/helpers/utils' import { getOS } from '@/helpers/utils'
import type { ColorTheme } from '@/store/theme.ts'
const themeStore = useTheming() const themeStore = useTheming()
@@ -25,13 +24,13 @@ watch(
<ThemeSelector <ThemeSelector
:update-color-theme=" :update-color-theme="
(theme: ColorTheme) => { (theme) => {
themeStore.setThemeState(theme) themeStore.setThemeState(theme)
settings.theme = theme settings.theme = theme
} }
" "
:current-theme="settings.theme" :current-theme="settings.theme"
:theme-options="themeStore.getThemeOptions()" :theme-options="themeStore.themeOptions"
system-theme-color="system" system-theme-color="system"
/> />
@@ -81,28 +80,10 @@ watch(
id="opening-page" id="opening-page"
v-model="settings.default_page" v-model="settings.default_page"
name="Opening page dropdown" name="Opening page dropdown"
class="w-40"
:options="['Home', 'Library']" :options="['Home', 'Library']"
/> />
</div> </div>
<div class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Jump back into worlds</h2>
<p class="m-0 mt-1">Includes recent worlds in the "Jump back in" section on the Home page.</p>
</div>
<Toggle
:model-value="themeStore.getFeatureFlag('worlds_in_home')"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag('worlds_in_home')
themeStore.featureFlags['worlds_in_home'] = newValue
settings.feature_flags['worlds_in_home'] = newValue
}
"
/>
</div>
<div class="mt-4 flex items-center justify-between"> <div class="mt-4 flex items-center justify-between">
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Toggle sidebar</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Toggle sidebar</h2>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { get, set } from '@/helpers/settings.ts' import { get, set } from '@/helpers/settings'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { get_max_memory } from '@/helpers/jre' import { get_max_memory } from '@/helpers/jre'
import { handleError } from '@/store/notifications' import { handleError } from '@/store/notifications'

View File

@@ -2,15 +2,18 @@
import { Toggle } from '@modrinth/ui' import { Toggle } from '@modrinth/ui'
import { useTheming } from '@/store/state' import { useTheming } from '@/store/state'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts' import { get, set } from '@/helpers/settings'
import { DEFAULT_FEATURE_FLAGS, type FeatureFlag } from '@/store/theme.ts'
const themeStore = useTheming() const themeStore = useTheming()
const settings = ref(await getSettings()) const settings = ref(await get())
const options = ref<FeatureFlag[]>(Object.keys(DEFAULT_FEATURE_FLAGS)) const options = ref(['project_background', 'page_path', 'worlds_tab'])
function setFeatureFlag(key: string, value: boolean) { function getStoreValue(key: string) {
return themeStore.featureFlags[key] ?? false
}
function setStoreValue(key: string, value: boolean) {
themeStore.featureFlags[key] = value themeStore.featureFlags[key] = value
settings.value.feature_flags[key] = value settings.value.feature_flags[key] = value
} }
@@ -18,7 +21,7 @@ function setFeatureFlag(key: string, value: boolean) {
watch( watch(
settings, settings,
async () => { async () => {
await setSettings(settings.value) await set(settings.value)
}, },
{ deep: true }, { deep: true },
) )
@@ -33,8 +36,8 @@ watch(
<Toggle <Toggle
id="advanced-rendering" id="advanced-rendering"
:model-value="themeStore.getFeatureFlag(option)" :model-value="getStoreValue(option)"
@update:model-value="() => setFeatureFlag(option, !themeStore.getFeatureFlag(option))" @update:model-value="() => setStoreValue(option, !themeStore.featureFlags[option])"
/> />
</div> </div>
</template> </template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts' import { get, set } from '@/helpers/settings'
import { Toggle } from '@modrinth/ui' import { Toggle } from '@modrinth/ui'
import { optInAnalytics, optOutAnalytics } from '@/helpers/analytics' import { optInAnalytics, optOutAnalytics } from '@/helpers/analytics'

View File

@@ -1,7 +1,7 @@
<script setup> <script setup>
import { Button, Slider } from '@modrinth/ui' import { Button, Slider } from '@modrinth/ui'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts' import { get, set } from '@/helpers/settings.js'
import { purge_cache_types } from '@/helpers/cache.js' import { purge_cache_types } from '@/helpers/cache.js'
import { handleError } from '@/store/notifications.js' import { handleError } from '@/store/notifications.js'
import { BoxIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets' import { BoxIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'

View File

@@ -8,14 +8,7 @@ import {
SpinnerIcon, SpinnerIcon,
StopCircleIcon, StopCircleIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { import { Avatar, ButtonStyled, commonMessages, OverflowMenu, SmartClickable } from '@modrinth/ui'
Avatar,
ButtonStyled,
commonMessages,
OverflowMenu,
SmartClickable,
useRelativeTime,
} from '@modrinth/ui'
import { useVIntl } from '@vintl/vintl' import { useVIntl } from '@vintl/vintl'
import { computed, nextTick, ref, onMounted, onUnmounted } from 'vue' import { computed, nextTick, ref, onMounted, onUnmounted } from 'vue'
import { showProfileInFolder } from '@/helpers/utils' import { showProfileInFolder } from '@/helpers/utils'
@@ -32,7 +25,6 @@ import { handleError } from '@/store/notifications'
import { process_listener } from '@/helpers/events' import { process_listener } from '@/helpers/events'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const router = useRouter() const router = useRouter()
@@ -152,7 +144,7 @@ onUnmounted(() => {
<template v-if="instance.last_played"> <template v-if="instance.last_played">
{{ {{
formatMessage(commonMessages.playedLabel, { formatMessage(commonMessages.playedLabel, {
time: formatRelativeTime(instance.last_played.toISOString()), time: dayjs(instance.last_played).fromNow(),
}) })
}} }}
</template> </template>
@@ -161,7 +153,7 @@ onUnmounted(() => {
<span v-if="modpack" class="flex items-center gap-1 truncate text-secondary"> <span v-if="modpack" class="flex items-center gap-1 truncate text-secondary">
<router-link <router-link
class="inline-flex items-center gap-1 truncate hover:underline text-secondary smart-clickable:allow-pointer-events" class="inline-flex items-center gap-1 truncate hover:underline text-secondary"
:to="`/project/${modpack.id}`" :to="`/project/${modpack.id}`"
> >
<Avatar :src="modpack.icon_url" size="16px" class="shrink-0" /> <Avatar :src="modpack.icon_url" size="16px" class="shrink-0" />

View File

@@ -13,17 +13,16 @@ import {
import { HeadingLink, GAME_MODES } from '@modrinth/ui' import { HeadingLink, GAME_MODES } from '@modrinth/ui'
import WorldItem from '@/components/ui/world/WorldItem.vue' import WorldItem from '@/components/ui/world/WorldItem.vue'
import InstanceItem from '@/components/ui/world/InstanceItem.vue' import InstanceItem from '@/components/ui/world/InstanceItem.vue'
import { watch, onMounted, onUnmounted, ref, computed } from 'vue' import { watch, onMounted, onUnmounted, ref } from 'vue'
import type { Dayjs } from 'dayjs' import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useTheming } from '@/store/theme.ts' import { useTheming } from '@/store/theme'
import { kill, run } from '@/helpers/profile' import { kill } from '@/helpers/profile'
import { handleError } from '@/store/notifications' import { handleError } from '@/store/notifications'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { process_listener, profile_listener } from '@/helpers/events' import { process_listener, profile_listener } from '@/helpers/events'
import { get_all } from '@/helpers/process' import { get_all } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { handleSevereError } from '@/store/error'
const props = defineProps<{ const props = defineProps<{
recentInstances: GameInstance[] recentInstances: GameInstance[]
@@ -55,9 +54,7 @@ type WorldJumpBackInItem = BaseJumpBackInItem & {
type JumpBackInItem = InstanceJumpBackInItem | WorldJumpBackInItem type JumpBackInItem = InstanceJumpBackInItem | WorldJumpBackInItem
const showWorlds = computed(() => theme.getFeatureFlag('worlds_in_home')) watch(props.recentInstances, async () => {
watch([() => props.recentInstances, () => showWorlds.value], async () => {
await populateJumpBackIn().catch(() => { await populateJumpBackIn().catch(() => {
console.error('Failed to populate jump back in') console.error('Failed to populate jump back in')
}) })
@@ -69,71 +66,66 @@ await populateJumpBackIn().catch(() => {
async function populateJumpBackIn() { async function populateJumpBackIn() {
console.info('Repopulating jump back in...') console.info('Repopulating jump back in...')
const worlds = await get_recent_worlds(MAX_JUMP_BACK_IN)
const worldItems: WorldJumpBackInItem[] = [] const worldItems: WorldJumpBackInItem[] = []
worlds.forEach((world) => {
const instance = props.recentInstances.find((instance) => instance.path === world.profile)
if (showWorlds.value) { if (!instance || !world.last_played) {
const worlds = await get_recent_worlds(MAX_JUMP_BACK_IN, ['normal', 'favorite']) return
}
worlds.forEach((world) => { worldItems.push({
const instance = props.recentInstances.find((instance) => instance.path === world.profile) type: 'world',
last_played: dayjs(world.last_played),
if (!instance || !world.last_played) { world: world,
return instance: instance,
}
worldItems.push({
type: 'world',
last_played: dayjs(world.last_played),
world: world,
instance: instance,
})
}) })
})
const servers: { const servers: {
instancePath: string instancePath: string
address: string address: string
}[] = worldItems }[] = worldItems
.filter((item) => item.world.type === 'server' && item.instance) .filter((item) => item.world.type === 'server' && item.instance)
.map((item) => ({ .map((item) => ({
instancePath: item.instance.path, instancePath: item.instance.path,
address: (item.world as ServerWorld).address, address: (item.world as ServerWorld).address,
})) }))
// fetch protocol versions for all unique MC versions with server worlds // fetch protocol versions for all unique MC versions with server worlds
const uniqueServerInstances = new Set<string>(servers.map((x) => x.instancePath)) const uniqueServerInstances = new Set<string>(servers.map((x) => x.instancePath))
await Promise.all( await Promise.all(
[...uniqueServerInstances].map((path) => [...uniqueServerInstances].map((path) => {
get_profile_protocol_version(path) get_profile_protocol_version(path)
.then((protoVer) => (protocolVersions.value[path] = protoVer)) .then((protoVer) => (protocolVersions.value[path] = protoVer))
.catch(() => { .catch(() => {
console.error(`Failed to get profile protocol for: ${path} `) console.error(`Failed to get profile protocol for: ${path} `)
}), })
), }),
) )
// initialize server data // initialize server data
servers.forEach(({ address }) => { servers.forEach(({ address }) => {
if (!serverData.value[address]) { if (!serverData.value[address]) {
serverData.value[address] = { serverData.value[address] = {
refreshing: true, refreshing: true,
}
} }
}) }
})
// fetch each server's data // fetch each server's data
Promise.all( await Promise.all(
servers.map(({ instancePath, address }) => servers.map(({ instancePath, address }) =>
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address), refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
), ),
) )
}
const instanceItems: InstanceJumpBackInItem[] = [] const instanceItems: InstanceJumpBackInItem[] = []
for (const instance of props.recentInstances) { props.recentInstances.forEach((instance) => {
const worldItem = worldItems.find((item) => item.instance.path === instance.path) if (worldItems.some((item) => item.instance.path === instance.path) || !instance.last_played) {
if ((worldItem && worldItem.last_played.isAfter(TWO_WEEKS_AGO)) || !instance.last_played) { return
continue
} }
instanceItems.push({ instanceItems.push({
@@ -141,13 +133,13 @@ async function populateJumpBackIn() {
last_played: dayjs(instance.last_played), last_played: dayjs(instance.last_played),
instance: instance, instance: instance,
}) })
} })
const items: JumpBackInItem[] = [...worldItems, ...instanceItems] const items: JumpBackInItem[] = [...worldItems, ...instanceItems]
items.sort((a, b) => dayjs(b.last_played).diff(dayjs(a.last_played))) items.sort((a, b) => dayjs(b.last_played).diff(dayjs(a.last_played)))
jumpBackInItems.value = items jumpBackInItems.value = items.filter(
.filter((item, index) => index < MIN_JUMP_BACK_IN || item.last_played.isAfter(TWO_WEEKS_AGO)) (item, index) => index < MIN_JUMP_BACK_IN || item.last_played.isAfter(TWO_WEEKS_AGO),
.slice(0, MAX_JUMP_BACK_IN) )
} }
async function refreshServer(address: string, instancePath: string) { async function refreshServer(address: string, instancePath: string) {
@@ -163,18 +155,6 @@ async function joinWorld(world: WorldWithProfile) {
} }
} }
async function playInstance(instance: GameInstance) {
await run(instance.path)
.catch((err) => handleSevereError(err, { profilePath: instance.path }))
.finally(() => {
trackEvent('InstancePlay', {
loader: instance.loader,
game_version: instance.game_version,
source: 'WorldItem',
})
})
}
async function stopInstance(path: string) { async function stopInstance(path: string) {
await kill(path).catch(handleError) await kill(path).catch(handleError)
trackEvent('InstanceStop', { trackEvent('InstanceStop', {
@@ -229,7 +209,11 @@ onUnmounted(() => {
<template> <template>
<div v-if="jumpBackInItems.length > 0" class="flex flex-col gap-2"> <div v-if="jumpBackInItems.length > 0" class="flex flex-col gap-2">
<HeadingLink v-if="theme.getFeatureFlag('worlds_tab')" to="/worlds" class="mt-1"> <HeadingLink
v-if="(theme.featureFlags as Record<string, boolean>)['worlds_tab']"
to="/worlds"
class="mt-1"
>
Jump back in Jump back in
</HeadingLink> </HeadingLink>
<span <span
@@ -238,7 +222,7 @@ onUnmounted(() => {
> >
Jump back in Jump back in
</span> </span>
<div class="grid-when-huge flex flex-col w-full gap-2"> <div class="flex flex-col w-full gap-2">
<template <template
v-for="item in jumpBackInItems" v-for="item in jumpBackInItems"
:key="`${item.instance.path}-${item.type === 'world' ? getWorldIdentifier(item.world) : 'instance'}`" :key="`${item.instance.path}-${item.type === 'world' ? getWorldIdentifier(item.world) : 'instance'}`"
@@ -262,7 +246,7 @@ onUnmounted(() => {
:rendered-motd=" :rendered-motd="
item.world.type === 'server' ? serverData[item.world.address].renderedMotd : undefined item.world.type === 'server' ? serverData[item.world.address].renderedMotd : undefined
" "
:current-protocol="protocolVersions[item.instance.path]" :current-protocol="protocolVersions[item.instance.game_version]"
:game-mode=" :game-mode="
item.world.type === 'singleplayer' ? GAME_MODES[item.world.game_mode] : undefined item.world.type === 'singleplayer' ? GAME_MODES[item.world.game_mode] : undefined
" "
@@ -275,7 +259,6 @@ onUnmounted(() => {
? refreshServer(item.world.address, item.instance.path) ? refreshServer(item.world.address, item.instance.path)
: {} : {}
" "
@update="() => populateJumpBackIn()"
@play=" @play="
() => { () => {
currentProfile = item.instance.path currentProfile = item.instance.path
@@ -283,12 +266,6 @@ onUnmounted(() => {
joinWorld(item.world) joinWorld(item.world)
} }
" "
@play-instance="
() => {
currentProfile = item.instance.path
playInstance(item.instance)
}
"
@stop="() => stopInstance(item.instance.path)" @stop="() => stopInstance(item.instance.path)"
/> />
<InstanceItem v-else :instance="item.instance" /> <InstanceItem v-else :instance="item.instance" />
@@ -296,9 +273,3 @@ onUnmounted(() => {
</div> </div>
</div> </div>
</template> </template>
<style scoped lang="scss">
.grid-when-huge {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(670px, 1fr));
}
</style>

View File

@@ -1,16 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import dayjs from 'dayjs' import dayjs from 'dayjs'
import type { ServerStatus, ServerWorld, SingleplayerWorld, World } from '@/helpers/worlds.ts' import type { ServerStatus, ServerWorld, World } from '@/helpers/worlds.ts'
import { set_world_display_status, getWorldIdentifier } from '@/helpers/worlds.ts' import { getWorldIdentifier, showWorldInFolder } from '@/helpers/worlds.ts'
import { formatNumber, getPingLevel } from '@modrinth/utils' import { formatNumber } from '@modrinth/utils'
import {
useRelativeTime,
Avatar,
ButtonStyled,
commonMessages,
OverflowMenu,
SmartClickable,
} from '@modrinth/ui'
import { import {
IssuesIcon, IssuesIcon,
EyeIcon, EyeIcon,
@@ -27,8 +19,8 @@ import {
TrashIcon, TrashIcon,
UpdatedIcon, UpdatedIcon,
UserIcon, UserIcon,
XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { Avatar, ButtonStyled, commonMessages, OverflowMenu, SmartClickable } from '@modrinth/ui'
import type { MessageDescriptor } from '@vintl/vintl' import type { MessageDescriptor } from '@vintl/vintl'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import type { Component } from 'vue' import type { Component } from 'vue'
@@ -39,13 +31,11 @@ import { useRouter } from 'vue-router'
import { Tooltip } from 'floating-vue' import { Tooltip } from 'floating-vue'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const router = useRouter() const router = useRouter()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'play' | 'play-instance' | 'update' | 'stop' | 'refresh' | 'edit' | 'delete'): void (e: 'play' | 'stop' | 'refresh' | 'edit' | 'delete'): void
(e: 'open-folder', world: SingleplayerWorld): void
}>() }>()
const props = withDefaults( const props = withDefaults(
@@ -79,7 +69,6 @@ const props = withDefaults(
playingWorld: false, playingWorld: false,
startingInstance: false, startingInstance: false,
supportsQuickPlay: false, supportsQuickPlay: false,
currentProtocol: null,
refreshing: false, refreshing: false,
serverStatus: undefined, serverStatus: undefined,
@@ -105,6 +94,20 @@ const serverIncompatible = computed(
props.serverStatus.version.protocol !== props.currentProtocol, props.serverStatus.version.protocol !== props.currentProtocol,
) )
function getPingLevel(ping: number) {
if (ping < 150) {
return 5
} else if (ping < 300) {
return 4
} else if (ping < 600) {
return 3
} else if (ping < 1000) {
return 2
} else {
return 1
}
}
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked) const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
const messages = defineMessages({ const messages = defineMessages({
@@ -140,18 +143,10 @@ const messages = defineMessages({
id: 'instance.worlds.play_anyway', id: 'instance.worlds.play_anyway',
defaultMessage: 'Play anyway', defaultMessage: 'Play anyway',
}, },
playInstance: {
id: 'instance.worlds.play_instance',
defaultMessage: 'Play instance',
},
worldInUse: { worldInUse: {
id: 'instance.worlds.world_in_use', id: 'instance.worlds.world_in_use',
defaultMessage: 'World is in use', defaultMessage: 'World is in use',
}, },
dontShowOnHome: {
id: 'instance.worlds.dont_show_on_home',
defaultMessage: `Don't show on Home`,
},
}) })
</script> </script>
<template> <template>
@@ -246,7 +241,7 @@ const messages = defineMessages({
<template v-if="world.last_played"> <template v-if="world.last_played">
{{ {{
formatMessage(commonMessages.playedLabel, { formatMessage(commonMessages.playedLabel, {
time: formatRelativeTime(dayjs(world.last_played).toISOString()), time: dayjs(world.last_played).fromNow(),
}) })
}} }}
</template> </template>
@@ -341,12 +336,6 @@ const messages = defineMessages({
<ButtonStyled circular type="transparent"> <ButtonStyled circular type="transparent">
<OverflowMenu <OverflowMenu
:options="[ :options="[
{
id: 'play-instance',
shown: !!instancePath,
disabled: playingInstance,
action: () => emit('play-instance'),
},
{ {
id: 'play-anyway', id: 'play-anyway',
shown: serverIncompatible && !playingInstance && supportsQuickPlay, shown: serverIncompatible && !playingInstance && supportsQuickPlay,
@@ -355,7 +344,7 @@ const messages = defineMessages({
{ {
id: 'open-instance', id: 'open-instance',
shown: !!instancePath, shown: !!instancePath,
action: () => router.push(encodeURI(`/instance/${instancePath}`)), action: () => router.push(encodeURI(`/instance/${instancePath}/worlds`)),
}, },
{ {
id: 'refresh', id: 'refresh',
@@ -377,25 +366,8 @@ const messages = defineMessages({
{ {
id: 'open-folder', id: 'open-folder',
shown: world.type === 'singleplayer', shown: world.type === 'singleplayer',
action: () => (world.type === 'singleplayer' ? emit('open-folder', world) : {}), action: () =>
}, world.type === 'singleplayer' ? showWorldInFolder(instancePath, world.path) : {},
{
divider: true,
shown: !!instancePath,
},
{
id: 'dont-show-on-home',
shown: !!instancePath,
action: () => {
set_world_display_status(
instancePath,
world.type,
getWorldIdentifier(world),
'hidden',
).then(() => {
emit('update')
})
},
}, },
{ {
divider: true, divider: true,
@@ -413,10 +385,6 @@ const messages = defineMessages({
]" ]"
> >
<MoreVerticalIcon aria-hidden="true" /> <MoreVerticalIcon aria-hidden="true" />
<template #play-instance>
<PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playInstance) }}
</template>
<template #play-anyway> <template #play-anyway>
<PlayIcon aria-hidden="true" /> <PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playAnyway) }} {{ formatMessage(messages.playAnyway) }}
@@ -438,10 +406,6 @@ const messages = defineMessages({
<template #refresh> <template #refresh>
<UpdatedIcon aria-hidden="true" /> {{ formatMessage(commonMessages.refreshButton) }} <UpdatedIcon aria-hidden="true" /> {{ formatMessage(commonMessages.refreshButton) }}
</template> </template>
<template #dont-show-on-home>
<XIcon aria-hidden="true" />
{{ formatMessage(messages.dontShowOnHome) }}
</template>
<template #delete> <template #delete>
<TrashIcon aria-hidden="true" /> <TrashIcon aria-hidden="true" />
{{ {{

View File

@@ -1,20 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { SaveIcon, XIcon } from '@modrinth/assets' import { SaveIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, commonMessages } from '@modrinth/ui' import { ButtonStyled, commonMessages } from '@modrinth/ui'
import { computed, ref } from 'vue' import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { import { edit_server_in_profile, type ServerWorld } from '@/helpers/worlds.ts'
type ServerPackStatus,
edit_server_in_profile,
type ServerWorld,
set_world_display_status,
type DisplayStatus,
} from '@/helpers/worlds.ts'
import { defineMessage, useVIntl } from '@vintl/vintl' import { defineMessage, useVIntl } from '@vintl/vintl'
import { handleError } from '@/store/notifications' import { handleError } from '@/store/notifications'
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue' import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
@@ -28,14 +21,10 @@ const props = defineProps<{
const modal = ref() const modal = ref()
const name = ref<string>('') const name = ref()
const address = ref<string>('') const address = ref()
const resourcePack = ref<ServerPackStatus>('enabled') const resourcePack = ref('enabled')
const index = ref<number>(0) const index = ref()
const displayStatus = ref<DisplayStatus>('normal')
const hideFromHome = ref(false)
const newDisplayStatus = computed(() => (hideFromHome.value ? 'hidden' : 'normal'))
async function saveServer() { async function saveServer() {
const serverName = name.value ? name.value : address.value const serverName = name.value ? name.value : address.value
@@ -47,23 +36,12 @@ async function saveServer() {
address.value, address.value,
resourcePackStatus, resourcePackStatus,
).catch(handleError) ).catch(handleError)
if (newDisplayStatus.value !== displayStatus.value) {
await set_world_display_status(
props.instance.path,
'server',
address.value,
newDisplayStatus.value,
).catch(handleError)
}
emit('submit', { emit('submit', {
name: serverName, name: serverName,
type: 'server', type: 'server',
index: index.value, index: index.value,
address: address.value, address: address.value,
pack_status: resourcePackStatus, pack_status: resourcePackStatus,
display_status: newDisplayStatus.value,
}) })
hide() hide()
} }
@@ -73,8 +51,6 @@ function show(server: ServerWorld) {
address.value = server.address address.value = server.address
resourcePack.value = server.pack_status resourcePack.value = server.pack_status
index.value = server.index index.value = server.index
displayStatus.value = server.display_status
hideFromHome.value = server.display_status === 'hidden'
modal.value.show() modal.value.show()
} }
@@ -99,7 +75,6 @@ const titleMessage = defineMessage({
v-model:address="address" v-model:address="address"
v-model:resource-pack="resourcePack" v-model:resource-pack="resourcePack"
/> />
<HideFromHomeOption v-model="hideFromHome" class="mt-3" />
<div class="flex gap-2 mt-4"> <div class="flex gap-2 mt-4">
<ButtonStyled color="brand"> <ButtonStyled color="brand">
<button :disabled="!address" @click="saveServer"> <button :disabled="!address" @click="saveServer">

View File

@@ -1,19 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { ChevronRightIcon, SaveIcon, XIcon, UndoIcon } from '@modrinth/assets' import { ChevronRightIcon, SaveIcon, XIcon, UndoIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, commonMessages } from '@modrinth/ui' import { Avatar, ButtonStyled, commonMessages } from '@modrinth/ui'
import { computed, ref } from 'vue' import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import type { DisplayStatus, SingleplayerWorld } from '@/helpers/worlds.ts' import type { SingleplayerWorld } from '@/helpers/worlds.ts'
import { set_world_display_status, rename_world, reset_world_icon } from '@/helpers/worlds.ts' import { rename_world, reset_world_icon } from '@/helpers/worlds.ts'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import { handleError } from '@/store/notifications' import { handleError } from '@/store/notifications'
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const emit = defineEmits<{ const emit = defineEmits<{
submit: [path: string, name: string, removeIcon: boolean, displayStatus: DisplayStatus] submit: [path: string, name: string, removeIcon: boolean]
}>() }>()
const props = defineProps<{ const props = defineProps<{
@@ -26,10 +25,6 @@ const icon = ref()
const name = ref() const name = ref()
const path = ref() const path = ref()
const removeIcon = ref(false) const removeIcon = ref(false)
const displayStatus = ref<DisplayStatus>('normal')
const hideFromHome = ref(false)
const newDisplayStatus = computed(() => (hideFromHome.value ? 'hidden' : 'normal'))
async function saveWorld() { async function saveWorld() {
await rename_world(props.instance.path, path.value, name.value).catch(handleError) await rename_world(props.instance.path, path.value, name.value).catch(handleError)
@@ -37,16 +32,8 @@ async function saveWorld() {
if (removeIcon.value) { if (removeIcon.value) {
await reset_world_icon(props.instance.path, path.value).catch(handleError) await reset_world_icon(props.instance.path, path.value).catch(handleError)
} }
if (newDisplayStatus.value !== displayStatus.value) {
await set_world_display_status(
props.instance.path,
'singleplayer',
path.value,
newDisplayStatus.value,
)
}
emit('submit', path.value, name.value, removeIcon.value, newDisplayStatus.value) emit('submit', path.value, name.value, removeIcon.value)
hide() hide()
} }
@@ -54,9 +41,6 @@ function show(world: SingleplayerWorld) {
name.value = world.name name.value = world.name
path.value = world.path path.value = world.path
icon.value = world.icon icon.value = world.icon
displayStatus.value = world.display_status
hideFromHome.value = world.display_status === 'hidden'
removeIcon.value = false
modal.value.show() modal.value.show()
} }
@@ -103,7 +87,6 @@ const messages = defineMessages({
class="w-full" class="w-full"
autocomplete="off" autocomplete="off"
/> />
<HideFromHomeOption v-model="hideFromHome" class="mt-3" />
</div> </div>
<div class="flex gap-2 mt-4"> <div class="flex gap-2 mt-4">
<ButtonStyled color="brand"> <ButtonStyled color="brand">

View File

@@ -1,18 +0,0 @@
<script setup lang="ts">
import { defineMessage, useVIntl } from '@vintl/vintl'
import { computed } from 'vue'
import { Checkbox } from '@modrinth/ui'
const { formatMessage } = useVIntl()
const value = defineModel<boolean>({ required: true })
const labelMessage = defineMessage({
id: 'instance.edit-world.hide-from-home',
defaultMessage: `Hide from the Home page`,
})
const label = computed(() => formatMessage(labelMessage))
</script>
<template>
<Checkbox v-model="value" :label="label" />
</template>

View File

@@ -7,13 +7,7 @@ import { invoke } from '@tauri-apps/api/core'
import { create } from './profile' import { create } from './profile'
// Installs pack from a version ID // Installs pack from a version ID
export async function create_profile_and_install( export async function create_profile_and_install(projectId, versionId, packTitle, iconUrl) {
projectId,
versionId,
packTitle,
iconUrl,
createInstanceCallback = () => {},
) {
const location = { const location = {
type: 'fromVersionId', type: 'fromVersionId',
project_id: projectId, project_id: projectId,
@@ -30,7 +24,6 @@ export async function create_profile_and_install(
null, null,
true, true,
) )
createInstanceCallback(profile)
return await invoke('plugin:pack|pack_install', { location, profile }) return await invoke('plugin:pack|pack_install', { location, profile })
} }

View File

@@ -0,0 +1,43 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/core'
// Settings object
/*
Settings {
"memory": MemorySettings,
"game_resolution": [int int],
"custom_java_args": [String ...],
"custom_env_args" : [(string, string) ... ]>,
"java_globals": Hash of (string, Path),
"default_user": Uuid string (can be null),
"hooks": Hooks,
"max_concurrent_downloads": uint,
"version": u32,
"collapsed_navigation": bool,
}
Memorysettings {
"min": u32, can be null,
"max": u32,
}
*/
// Get full settings object
export async function get() {
return await invoke('plugin:settings|settings_get')
}
// Set full settings object
export async function set(settings) {
return await invoke('plugin:settings|settings_set', { settings })
}
export async function cancel_directory_change() {
return await invoke('plugin:settings|cancel_directory_change')
}

View File

@@ -1,78 +0,0 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/core'
import type { ColorTheme, FeatureFlag } from '@/store/theme.ts'
import type { Hooks, MemorySettings, WindowSize } from '@/helpers/types'
// Settings object
/*
Settings {
"memory": MemorySettings,
"game_resolution": [int int],
"custom_java_args": [String ...],
"custom_env_args" : [(string, string) ... ]>,
"java_globals": Hash of (string, Path),
"default_user": Uuid string (can be null),
"hooks": Hooks,
"max_concurrent_downloads": uint,
"version": u32,
"collapsed_navigation": bool,
}
Memorysettings {
"min": u32, can be null,
"max": u32,
}
*/
export type AppSettings = {
max_concurrent_downloads: number
max_concurrent_writes: number
theme: ColorTheme
default_page: 'home' | 'library'
collapsed_navigation: boolean
advanced_rendering: boolean
native_decorations: boolean
toggle_sidebar: boolean
telemetry: boolean
discord_rpc: boolean
personalized_ads: boolean
onboarded: boolean
extra_launch_args: string[]
custom_env_vars: [string, string][]
memory: MemorySettings
force_fullscreen: boolean
game_resolution: WindowSize
hide_on_process_start: boolean
hooks: Hooks
custom_dir?: string | null
prev_custom_dir?: string | null
migrated: boolean
developer_mode: boolean
feature_flags: Record<FeatureFlag, boolean>
}
// Get full settings object
export async function get() {
return (await invoke('plugin:settings|settings_get')) as AppSettings
}
// Set full settings object
export async function set(settings: AppSettings) {
return await invoke('plugin:settings|settings_set', { settings })
}
export async function cancel_directory_change(): Promise<void> {
return await invoke('plugin:settings|cancel_directory_change')
}

View File

@@ -48,32 +48,6 @@ type LinkedData = {
type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge' type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge'
type ContentFile = {
hash: string
file_name: string
size: number
metadata?: FileMetadata
update_version_id?: string
project_type: ContentFileProjectType
}
type FileMetadata = {
project_id: string
version_id: string
}
type ContentFileProjectType = 'mod' | 'datapack' | 'resourcepack' | 'shaderpack'
type CacheBehaviour =
// Serve expired data. If fetch fails / launcher is offline, errors are ignored
| 'stale_while_revalidate_skip_offline'
// Serve expired data, revalidate in background
| 'stale_while_revalidate'
// Must revalidate if data is expired
| 'must_revalidate'
// Ignore cache- always fetch updated data from origin
| 'bypass'
type MemorySettings = { type MemorySettings = {
maximum: number maximum: number
} }
@@ -114,7 +88,6 @@ type AppSettings = {
collapsed_navigation: boolean collapsed_navigation: boolean
advanced_rendering: boolean advanced_rendering: boolean
native_decorations: boolean native_decorations: boolean
worlds_in_home: boolean
telemetry: boolean telemetry: boolean
discord_rpc: boolean discord_rpc: boolean

View File

@@ -9,13 +9,8 @@ type BaseWorld = {
name: string name: string
last_played?: string last_played?: string
icon?: string icon?: string
display_status: DisplayStatus
type: WorldType
} }
export type WorldType = 'singleplayer' | 'server'
export type DisplayStatus = 'normal' | 'hidden' | 'favorite'
export type SingleplayerWorld = BaseWorld & { export type SingleplayerWorld = BaseWorld & {
type: 'singleplayer' type: 'singleplayer'
path: string path: string
@@ -75,11 +70,8 @@ export type ServerData = {
renderedMotd?: string renderedMotd?: string
} }
export async function get_recent_worlds( export async function get_recent_worlds(limit: number): Promise<WorldWithProfile[]> {
limit: number, return await invoke('plugin:worlds|get_recent_worlds', { limit })
displayStatuses?: DisplayStatus[],
): Promise<WorldWithProfile[]> {
return await invoke('plugin:worlds|get_recent_worlds', { limit, displayStatuses })
} }
export async function get_profile_worlds(path: string): Promise<World[]> { export async function get_profile_worlds(path: string): Promise<World[]> {
@@ -93,20 +85,6 @@ export async function get_singleplayer_world(
return await invoke('plugin:worlds|get_singleplayer_world', { instance, world }) return await invoke('plugin:worlds|get_singleplayer_world', { instance, world })
} }
export async function set_world_display_status(
instance: string,
worldType: WorldType,
worldId: string,
displayStatus: DisplayStatus,
): Promise<void> {
return await invoke('plugin:worlds|set_world_display_status', {
instance,
worldType,
worldId,
displayStatus,
})
}
export async function rename_world( export async function rename_world(
instance: string, instance: string,
world: string, world: string,
@@ -252,14 +230,12 @@ export async function refreshServers(
export async function refreshWorld(worlds: World[], instancePath: string, worldPath: string) { export async function refreshWorld(worlds: World[], instancePath: string, worldPath: string) {
const index = worlds.findIndex((w) => w.type === 'singleplayer' && w.path === worldPath) const index = worlds.findIndex((w) => w.type === 'singleplayer' && w.path === worldPath)
const newWorld = await get_singleplayer_world(instancePath, worldPath)
if (index !== -1) { if (index !== -1) {
worlds[index] = newWorld worlds[index] = await get_singleplayer_world(instancePath, worldPath)
sortWorlds(worlds)
} else { } else {
console.info(`Adding new world at path: ${worldPath}.`) console.error(`Error refreshing world, could not find world at path ${worldPath}.`)
worlds.push(newWorld)
} }
sortWorlds(worlds)
} }
export async function handleDefaultProfileUpdateEvent( export async function handleDefaultProfileUpdateEvent(

View File

@@ -41,9 +41,6 @@
"instance.edit-server.title": { "instance.edit-server.title": {
"message": "Edit server" "message": "Edit server"
}, },
"instance.edit-world.hide-from-home": {
"message": "Hide from the Home page"
},
"instance.edit-world.name": { "instance.edit-world.name": {
"message": "Name" "message": "Name"
}, },
@@ -365,9 +362,6 @@
"instance.worlds.copy_address": { "instance.worlds.copy_address": {
"message": "Copy address" "message": "Copy address"
}, },
"instance.worlds.dont_show_on_home": {
"message": "Don't show on Home"
},
"instance.worlds.filter.available": { "instance.worlds.filter.available": {
"message": "Available" "message": "Available"
}, },
@@ -383,9 +377,6 @@
"instance.worlds.play_anyway": { "instance.worlds.play_anyway": {
"message": "Play anyway" "message": "Play anyway"
}, },
"instance.worlds.play_instance": {
"message": "Play instance"
},
"instance.worlds.type.server": { "instance.worlds.type.server": {
"message": "Server" "message": "Server"
}, },

View File

@@ -32,11 +32,7 @@
<template #actions> <template #actions>
<div class="flex gap-2"> <div class="flex gap-2">
<ButtonStyled <ButtonStyled
v-if=" v-if="instance.install_stage.includes('installing')"
['installing', 'pack_installing', 'minecraft_installing'].includes(
instance.install_stage,
)
"
color="brand" color="brand"
size="large" size="large"
> >

View File

@@ -22,7 +22,7 @@
<FilterIcon class="text-secondary h-5 w-5 mr-1" /> <FilterIcon class="text-secondary h-5 w-5 mr-1" />
<button <button
v-for="filter in filterOptions" v-for="filter in filterOptions"
:key="`content-filter-${filter.id}`" :key="filter"
:class="`px-2 py-1 rounded-full font-semibold leading-none border-none cursor-pointer active:scale-[0.97] duration-100 transition-all ${selectedFilters.includes(filter.id) ? 'bg-brand-highlight text-brand' : 'bg-bg-raised text-secondary'}`" :class="`px-2 py-1 rounded-full font-semibold leading-none border-none cursor-pointer active:scale-[0.97] duration-100 transition-all ${selectedFilters.includes(filter.id) ? 'bg-brand-highlight text-brand' : 'bg-bg-raised text-secondary'}`"
@click="toggleArray(selectedFilters, filter.id)" @click="toggleArray(selectedFilters, filter.id)"
> >
@@ -47,7 +47,7 @@
path: x.path, path: x.path,
disabled: x.disabled, disabled: x.disabled,
filename: x.file_name, filename: x.file_name,
icon: x.icon ?? undefined, icon: x.icon,
title: x.name, title: x.name,
data: x, data: x,
} }
@@ -156,7 +156,7 @@
color-fill="text" color-fill="text"
hover-color-fill="text" hover-color-fill="text"
> >
<button class="w-max" :disabled="installing" @click="modpackVersionModal?.show()"> <button class="w-max" :disabled="installing" @click="modpackVersionModal.show()">
<DownloadIcon /> Update pack <DownloadIcon /> Update pack
</button> </button>
</ButtonStyled> </ButtonStyled>
@@ -170,7 +170,7 @@
> >
<button <button
v-tooltip="`Update`" v-tooltip="`Update`"
:disabled="(item.data as ProjectListEntry).updating" :disabled="(item.data as any).updating"
@click="updateProject(item.data)" @click="updateProject(item.data)"
> >
<DownloadIcon /> <DownloadIcon />
@@ -276,7 +276,6 @@ import {
RadialHeader, RadialHeader,
Toggle, Toggle,
} from '@modrinth/ui' } from '@modrinth/ui'
import type { Organization, Project, TeamMember, Version } from '@modrinth/utils'
import { formatProjectType } from '@modrinth/utils' import { formatProjectType } from '@modrinth/utils'
import type { ComputedRef } from 'vue' import type { ComputedRef } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue' import { computed, onUnmounted, ref, watch } from 'vue'
@@ -306,18 +305,43 @@ import { profile_listener } from '@/helpers/events.js'
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue' import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { getCurrentWebview } from '@tauri-apps/api/webview' import { getCurrentWebview } from '@tauri-apps/api/webview'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import type { CacheBehaviour, ContentFile, GameInstance } from '@/helpers/types'
import type ContextMenu from '@/components/ui/ContextMenu.vue'
import type { ContentItem } from '@modrinth/ui/src/components/content/ContentListItem.vue'
const props = defineProps<{ const props = defineProps({
instance: GameInstance instance: {
options: InstanceType<typeof ContextMenu> type: Object,
offline: boolean default() {
playing: boolean return {}
versions: Version[] },
installed: boolean },
}>() options: {
type: Object,
default() {
return {}
},
},
offline: {
type: Boolean,
default() {
return false
},
},
playing: {
type: Boolean,
default() {
return false
},
},
versions: {
type: Array,
required: true,
},
installed: {
type: Boolean,
default() {
return false
},
},
})
type ProjectListEntryAuthor = { type ProjectListEntryAuthor = {
name: string name: string
@@ -332,15 +356,13 @@ type ProjectListEntry = {
author: ProjectListEntryAuthor | null author: ProjectListEntryAuthor | null
version: string | null version: string | null
file_name: string file_name: string
icon: string | undefined icon: string | null
disabled: boolean disabled: boolean
updateVersion?: string updateVersion?: string
outdated: boolean outdated: boolean
updated: dayjs.Dayjs updated: dayjs.Dayjs
project_type: string project_type: string
id?: string id?: string
updating?: boolean
selected?: boolean
} }
const isPackLocked = computed(() => { const isPackLocked = computed(() => {
@@ -353,20 +375,17 @@ const canUpdatePack = computed(() => {
const exportModal = ref(null) const exportModal = ref(null)
const projects = ref<ProjectListEntry[]>([]) const projects = ref<ProjectListEntry[]>([])
const selectedFiles = ref<string[]>([]) const selectedFiles = ref([])
const selectedProjects = computed(() => const selectedProjects = computed(() =>
projects.value.filter((x) => selectedFiles.value.includes(x.file_name)), projects.value.filter((x) => selectedFiles.value.includes(x.file_name)),
) )
const selectionMap = ref(new Map()) const selectionMap = ref(new Map())
const initProjects = async (cacheBehaviour?: CacheBehaviour) => { const initProjects = async (cacheBehaviour?) => {
const newProjects: ProjectListEntry[] = [] const newProjects: ProjectListEntry[] = []
const profileProjects = (await get_projects(props.instance.path, cacheBehaviour)) as Record< const profileProjects = await get_projects(props.instance.path, cacheBehaviour)
string,
ContentFile
>
const fetchProjects = [] const fetchProjects = []
const fetchVersions = [] const fetchVersions = []
@@ -378,21 +397,21 @@ const initProjects = async (cacheBehaviour?: CacheBehaviour) => {
} }
const [modrinthProjects, modrinthVersions] = await Promise.all([ const [modrinthProjects, modrinthVersions] = await Promise.all([
(await get_project_many(fetchProjects).catch(handleError)) as Project[], await get_project_many(fetchProjects).catch(handleError),
(await get_version_many(fetchVersions).catch(handleError)) as Version[], await get_version_many(fetchVersions).catch(handleError),
]) ])
const [modrinthTeams, modrinthOrganizations] = await Promise.all([ const [modrinthTeams, modrinthOrganizations] = await Promise.all([
(await get_team_many(modrinthProjects.map((x) => x.team)).catch(handleError)) as TeamMember[][], await get_team_many(modrinthProjects.map((x) => x.team)).catch(handleError),
(await get_organization_many( await get_organization_many(
modrinthProjects.map((x) => x.organization).filter((x) => !!x), modrinthProjects.map((x) => x.organization).filter((x) => !!x),
).catch(handleError)) as Organization[], ).catch(handleError),
]) ])
for (const [path, file] of Object.entries(profileProjects)) { for (const [path, file] of Object.entries(profileProjects)) {
if (file.metadata) { if (file.metadata) {
const project = modrinthProjects.find((x) => file.metadata?.project_id === x.id) const project = modrinthProjects.find((x) => file.metadata.project_id === x.id)
const version = modrinthVersions.find((x) => file.metadata?.version_id === x.id) const version = modrinthVersions.find((x) => file.metadata.version_id === x.id)
if (project && version) { if (project && version) {
const org = project.organization const org = project.organization
@@ -401,7 +420,7 @@ const initProjects = async (cacheBehaviour?: CacheBehaviour) => {
const team = modrinthTeams.find((x) => x[0].team_id === project.team) const team = modrinthTeams.find((x) => x[0].team_id === project.team)
let author: ProjectListEntryAuthor | null = null let author: ProjectListEntryAuthor | null
if (org) { if (org) {
author = { author = {
name: org.name, name: org.name,
@@ -410,13 +429,13 @@ const initProjects = async (cacheBehaviour?: CacheBehaviour) => {
} }
} else if (team) { } else if (team) {
const teamMember = team.find((x) => x.is_owner) const teamMember = team.find((x) => x.is_owner)
if (teamMember) { author = {
author = { name: teamMember.user.username,
name: teamMember.user.username, slug: teamMember.user.username,
slug: teamMember.user.username, type: 'user',
type: 'user',
}
} }
} else {
author = null
} }
newProjects.push({ newProjects.push({
@@ -445,7 +464,7 @@ const initProjects = async (cacheBehaviour?: CacheBehaviour) => {
author: null, author: null,
version: null, version: null,
file_name: file.file_name, file_name: file.file_name,
icon: undefined, icon: null,
disabled: file.file_name.endsWith('.disabled'), disabled: file.file_name.endsWith('.disabled'),
outdated: false, outdated: false,
updated: dayjs(0), updated: dayjs(0),
@@ -469,7 +488,7 @@ const initProjects = async (cacheBehaviour?: CacheBehaviour) => {
} }
await initProjects() await initProjects()
const modpackVersionModal = ref<InstanceType<typeof ModpackVersionModal> | null>() const modpackVersionModal = ref(null)
const installing = computed(() => props.instance.install_stage !== 'installed') const installing = computed(() => props.instance.install_stage !== 'installed')
const vintl = useVIntl() const vintl = useVIntl()
@@ -494,7 +513,7 @@ const messages = defineMessages({
const filterOptions: ComputedRef<FilterOption[]> = computed(() => { const filterOptions: ComputedRef<FilterOption[]> = computed(() => {
const options: FilterOption[] = [] const options: FilterOption[] = []
const frequency = projects.value.reduce((map: Record<string, number>, item) => { const frequency = projects.value.reduce((map, item) => {
map[item.project_type] = (map[item.project_type] || 0) + 1 map[item.project_type] = (map[item.project_type] || 0) + 1
return map return map
}, {}) }, {})
@@ -525,7 +544,7 @@ const filterOptions: ComputedRef<FilterOption[]> = computed(() => {
return options return options
}) })
const selectedFilters = ref<string[]>([]) const selectedFilters = ref([])
const filteredProjects = computed(() => { const filteredProjects = computed(() => {
const updatesFilter = selectedFilters.value.includes('updates') const updatesFilter = selectedFilters.value.includes('updates')
const disabledFilter = selectedFilters.value.includes('disabled') const disabledFilter = selectedFilters.value.includes('disabled')
@@ -552,7 +571,7 @@ watch(filterOptions, () => {
} }
}) })
function toggleArray<T>(array: T[], value: T) { function toggleArray(array, value) {
if (array.includes(value)) { if (array.includes(value)) {
array.splice(array.indexOf(value), 1) array.splice(array.indexOf(value), 1)
} else { } else {
@@ -562,7 +581,7 @@ function toggleArray<T>(array: T[], value: T) {
const searchFilter = ref('') const searchFilter = ref('')
const selectAll = ref(false) const selectAll = ref(false)
const shareModal = ref<InstanceType<typeof ShareModalWrapper> | null>() const shareModal = ref(null)
const ascending = ref(true) const ascending = ref(true)
const sortColumn = ref('Name') const sortColumn = ref('Name')
const currentPage = ref(1) const currentPage = ref(1)
@@ -603,7 +622,7 @@ const search = computed(() => {
watch([sortColumn, ascending, selectedFilters.value, searchFilter], () => (currentPage.value = 1)) watch([sortColumn, ascending, selectedFilters.value, searchFilter], () => (currentPage.value = 1))
const sortProjects = (filter: string) => { const sortProjects = (filter) => {
if (sortColumn.value === filter) { if (sortColumn.value === filter) {
ascending.value = !ascending.value ascending.value = !ascending.value
} else { } else {
@@ -621,7 +640,7 @@ const updateAll = async () => {
} }
} }
const paths = (await update_all(props.instance.path).catch(handleError)) as Record<string, string> const paths = await update_all(props.instance.path).catch(handleError)
for (const [oldVal, newVal] of Object.entries(paths)) { for (const [oldVal, newVal] of Object.entries(paths)) {
const index = projects.value.findIndex((x) => x.path === oldVal) const index = projects.value.findIndex((x) => x.path === oldVal)
@@ -630,7 +649,7 @@ const updateAll = async () => {
if (projects.value[index].updateVersion) { if (projects.value[index].updateVersion) {
projects.value[index].version = projects.value[index].updateVersion.version_number projects.value[index].version = projects.value[index].updateVersion.version_number
projects.value[index].updateVersion = undefined projects.value[index].updateVersion = null
} }
} }
for (const project of setProjects) { for (const project of setProjects) {
@@ -645,15 +664,15 @@ const updateAll = async () => {
}) })
} }
const updateProject = async (mod: ProjectListEntry) => { const updateProject = async (mod) => {
mod.updating = true mod.updating = true
await new Promise((resolve) => setTimeout(resolve, 0)) await new Promise((resolve) => setTimeout(resolve, 0))
mod.path = await update_project(props.instance.path, mod.path).catch(handleError) mod.path = await update_project(props.instance.path, mod.path).catch(handleError)
mod.updating = false mod.updating = false
mod.outdated = false mod.outdated = false
mod.version = mod.updateVersion?.version_number mod.version = mod.updateVersion.version_number
mod.updateVersion = undefined mod.updateVersion = null
trackEvent('InstanceProjectUpdate', { trackEvent('InstanceProjectUpdate', {
loader: props.instance.loader, loader: props.instance.loader,
@@ -664,15 +683,15 @@ const updateProject = async (mod: ProjectListEntry) => {
}) })
} }
const locks: Record<string, string | null> = {} const locks = {}
const toggleDisableMod = async (mod: ProjectListEntry) => { const toggleDisableMod = async (mod) => {
// Use mod's id as the key for the lock. If mod doesn't have a unique id, replace `mod.id` with some unique property. // Use mod's id as the key for the lock. If mod doesn't have a unique id, replace `mod.id` with some unique property.
const lock = locks[mod.file_name] const lock = locks[mod.file_name]
while (lock) { while (lock) {
await new Promise((resolve) => { await new Promise((resolve) => {
setTimeout((value: unknown) => resolve(value), 100) setTimeout((_) => resolve(), 100)
}) })
} }
@@ -697,20 +716,20 @@ const toggleDisableMod = async (mod: ProjectListEntry) => {
locks[mod.file_name] = null locks[mod.file_name] = null
} }
const removeMod = async (mod: ContentItem<ProjectListEntry>) => { const removeMod = async (mod) => {
await remove_project(props.instance.path, mod.path).catch(handleError) await remove_project(props.instance.path, mod.path).catch(handleError)
projects.value = projects.value.filter((x) => mod.path !== x.path) projects.value = projects.value.filter((x) => mod.path !== x.path)
trackEvent('InstanceProjectRemove', { trackEvent('InstanceProjectRemove', {
loader: props.instance.loader, loader: props.instance.loader,
game_version: props.instance.game_version, game_version: props.instance.game_version,
id: mod.data.id, id: mod.id,
name: mod.data.name, name: mod.name,
project_type: mod.data.project_type, project_type: mod.project_type,
}) })
} }
const copyModLink = async (mod: ContentItem<ProjectListEntry>) => { const copyModLink = async (mod) => {
await navigator.clipboard.writeText( await navigator.clipboard.writeText(
`https://modrinth.com/${mod.data.project_type}/${mod.data.slug}`, `https://modrinth.com/${mod.data.project_type}/${mod.data.slug}`,
) )
@@ -725,15 +744,15 @@ const deleteSelected = async () => {
} }
const shareNames = async () => { const shareNames = async () => {
await shareModal.value?.show(functionValues.value.map((x) => x.name).join('\n')) await shareModal.value.show(functionValues.value.map((x) => x.name).join('\n'))
} }
const shareFileNames = async () => { const shareFileNames = async () => {
await shareModal.value?.show(functionValues.value.map((x) => x.file_name).join('\n')) await shareModal.value.show(functionValues.value.map((x) => x.file_name).join('\n'))
} }
const shareUrls = async () => { const shareUrls = async () => {
await shareModal.value?.show( await shareModal.value.show(
functionValues.value functionValues.value
.filter((x) => x.slug) .filter((x) => x.slug)
.map((x) => `https://modrinth.com/${x.project_type}/${x.slug}`) .map((x) => `https://modrinth.com/${x.project_type}/${x.slug}`)
@@ -742,7 +761,7 @@ const shareUrls = async () => {
} }
const shareMarkdown = async () => { const shareMarkdown = async () => {
await shareModal.value?.show( await shareModal.value.show(
functionValues.value functionValues.value
.map((x) => { .map((x) => {
if (x.slug) { if (x.slug) {
@@ -807,17 +826,15 @@ const unlisten = await getCurrentWebview().onDragDropEvent(async (event) => {
await initProjects() await initProjects()
}) })
const unlistenProfiles = await profile_listener( const unlistenProfiles = await profile_listener(async (event) => {
async (event: { event: string; profile_path_id: string }) => { if (
if ( event.profile_path_id === props.instance.path &&
event.profile_path_id === props.instance.path && event.event === 'synced' &&
event.event === 'synced' && props.instance.install_stage !== 'pack_installing'
props.instance.install_stage !== 'pack_installing' ) {
) { await initProjects()
await initProjects() }
} })
},
)
onUnmounted(() => { onUnmounted(() => {
unlisten() unlisten()

View File

@@ -60,7 +60,7 @@
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
<FilterBar v-model="filters" :options="filterOptions" show-all-options /> <FilterBar v-model="filters" :options="filterOptions" />
<div class="flex flex-col w-full gap-2"> <div class="flex flex-col w-full gap-2">
<WorldItem <WorldItem
v-for="world in filteredWorlds" v-for="world in filteredWorlds"
@@ -86,7 +86,6 @@
world.type === 'server' ? editServerModal?.show(world) : editWorldModal?.show(world) world.type === 'server' ? editServerModal?.show(world) : editWorldModal?.show(world)
" "
@delete="() => promptToRemoveWorld(world)" @delete="() => promptToRemoveWorld(world)"
@open-folder="(world: SingleplayerWorld) => showWorldInFolder(instance.path, world.path)"
/> />
</div> </div>
</div> </div>
@@ -152,7 +151,6 @@ import {
hasQuickPlaySupport, hasQuickPlaySupport,
refreshWorlds, refreshWorlds,
handleDefaultProfileUpdateEvent, handleDefaultProfileUpdateEvent,
showWorldInFolder,
} from '@/helpers/worlds.ts' } from '@/helpers/worlds.ts'
import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue' import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue' import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
@@ -227,11 +225,6 @@ const unlistenProfile = await profile_listener(async (e: ProfileEvent) => {
await refreshAllWorlds() await refreshAllWorlds()
async function refreshServer(address: string) { async function refreshServer(address: string) {
if (!serverData.value[address]) {
serverData.value[address] = {
refreshing: true,
}
}
await refreshServerData(serverData.value[address], protocolVersion.value, address) await refreshServerData(serverData.value[address], protocolVersion.value, address)
} }
@@ -270,12 +263,9 @@ async function addServer(server: ServerWorld) {
async function editServer(server: ServerWorld) { async function editServer(server: ServerWorld) {
const index = worlds.value.findIndex((w) => w.type === 'server' && w.index === server.index) const index = worlds.value.findIndex((w) => w.type === 'server' && w.index === server.index)
if (index !== -1) { if (index !== -1) {
const oldServer = worlds.value[index] as ServerWorld
worlds.value[index] = server worlds.value[index] = server
sortWorlds(worlds.value) sortWorlds(worlds.value)
if (oldServer.address !== server.address) { await refreshServer(server.address)
await refreshServer(server.address)
}
} else { } else {
handleError(`Error refreshing server, refreshing all worlds`) handleError(`Error refreshing server, refreshing all worlds`)
await refreshAllWorlds() await refreshAllWorlds()
@@ -359,34 +349,26 @@ const supportsQuickPlay = computed(() =>
const filterOptions = computed(() => { const filterOptions = computed(() => {
const options: FilterBarOption[] = [] const options: FilterBarOption[] = []
const hasServer = worlds.value.some((x) => x.type === 'server') if (worlds.value.some((x) => x.type === 'singleplayer')) {
if (worlds.value.some((x) => x.type === 'singleplayer') && hasServer) {
options.push({ options.push({
id: 'singleplayer', id: 'singleplayer',
message: messages.singleplayer, message: messages.singleplayer,
}) })
}
if (worlds.value.some((x) => x.type === 'server')) {
options.push({ options.push({
id: 'server', id: 'server',
message: messages.server, message: messages.server,
}) })
}
if (hasServer) { // add available filter if there's any offline ("unavailable") servers
// add available filter if there's any offline ("unavailable") servers AND there's any singleplayer worlds or available servers
if ( if (
worlds.value.some( worlds.value.some(
(x) => (x) =>
x.type === 'server' && x.type === 'server' &&
!serverData.value[x.address]?.status && !serverData.value[x.address]?.status &&
!serverData.value[x.address]?.refreshing, !serverData.value[x.address]?.refreshing,
) &&
worlds.value.some(
(x) =>
x.type === 'singleplayer' ||
(x.type === 'server' &&
serverData.value[x.address]?.status &&
!serverData.value[x.address]?.refreshing),
) )
) { ) {
options.push({ options.push({

View File

@@ -155,7 +155,7 @@ import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile' import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime' import relativeTime from 'dayjs/plugin/relativeTime'
import { useRoute, useRouter } from 'vue-router' import { useRoute } from 'vue-router'
import { ref, shallowRef, watch } from 'vue' import { ref, shallowRef, watch } from 'vue'
import { useBreadcrumbs } from '@/store/breadcrumbs' import { useBreadcrumbs } from '@/store/breadcrumbs'
import { handleError } from '@/store/notifications.js' import { handleError } from '@/store/notifications.js'
@@ -170,7 +170,6 @@ import { openUrl } from '@tauri-apps/plugin-opener'
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
const route = useRoute() const route = useRoute()
const router = useRouter()
const breadcrumbs = useBreadcrumbs() const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming() const themeStore = useTheming()
@@ -248,9 +247,6 @@ async function install(version) {
installedVersion.value = version installedVersion.value = version
} }
}, },
(profile) => {
router.push(`/instance/${profile}`)
},
) )
} }

View File

@@ -23,8 +23,8 @@ export const useInstall = defineStore('installStore', {
setInstallConfirmModal(ref) { setInstallConfirmModal(ref) {
this.installConfirmModal = ref this.installConfirmModal = ref
}, },
showInstallConfirmModal(project, version_id, onInstall, createInstanceCallback) { showInstallConfirmModal(project, version_id, onInstall) {
this.installConfirmModal.show(project, version_id, onInstall, createInstanceCallback) this.installConfirmModal.show(project, version_id, onInstall)
}, },
setIncompatibilityWarningModal(ref) { setIncompatibilityWarningModal(ref) {
this.incompatibilityWarningModal = ref this.incompatibilityWarningModal = ref
@@ -41,14 +41,7 @@ export const useInstall = defineStore('installStore', {
}, },
}) })
export const install = async ( export const install = async (projectId, versionId, instancePath, source, callback = () => {}) => {
projectId,
versionId,
instancePath,
source,
callback = () => {},
createInstanceCallback = () => {},
) => {
const project = await get_project(projectId, 'must_revalidate').catch(handleError) const project = await get_project(projectId, 'must_revalidate').catch(handleError)
if (project.project_type === 'modpack') { if (project.project_type === 'modpack') {
@@ -56,13 +49,7 @@ export const install = async (
const packs = await list().catch(handleError) const packs = await list().catch(handleError)
if (packs.length === 0 || !packs.find((pack) => pack.linked_data?.project_id === project.id)) { if (packs.length === 0 || !packs.find((pack) => pack.linked_data?.project_id === project.id)) {
await packInstall( await packInstall(project.id, version, project.title, project.icon_url).catch(handleError)
project.id,
version,
project.title,
project.icon_url,
createInstanceCallback,
).catch(handleError)
trackEvent('PackInstall', { trackEvent('PackInstall', {
id: project.id, id: project.id,
@@ -74,7 +61,7 @@ export const install = async (
callback(version) callback(version)
} else { } else {
const install = useInstall() const install = useInstall()
install.showInstallConfirmModal(project, version, callback, createInstanceCallback) install.showInstallConfirmModal(project, version, callback)
} }
} else { } else {
if (instancePath) { if (instancePath) {

View File

@@ -1,4 +1,4 @@
import { useTheming } from './theme.ts' import { useTheming } from './theme'
import { useBreadcrumbs } from './breadcrumbs' import { useBreadcrumbs } from './breadcrumbs'
import { useLoading } from './loading' import { useLoading } from './loading'
import { useNotifications, handleError } from './notifications' import { useNotifications, handleError } from './notifications'

View File

@@ -0,0 +1,38 @@
import { defineStore } from 'pinia'
export const useTheming = defineStore('themeStore', {
state: () => ({
themeOptions: ['dark', 'light', 'oled', 'system'],
advancedRendering: true,
selectedTheme: 'dark',
toggleSidebar: false,
devMode: false,
featureFlags: {},
}),
actions: {
setThemeState(newTheme) {
if (this.themeOptions.includes(newTheme)) this.selectedTheme = newTheme
else console.warn('Selected theme is not present. Check themeOptions.')
this.setThemeClass()
},
setThemeClass() {
for (const theme of this.themeOptions) {
document.getElementsByTagName('html')[0].classList.remove(`${theme}-mode`)
}
let theme = this.selectedTheme
if (this.selectedTheme === 'system') {
const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
if (darkThemeMq.matches) {
theme = 'dark'
} else {
theme = 'light'
}
}
document.getElementsByTagName('html')[0].classList.add(`${theme}-mode`)
},
},
})

View File

@@ -1,70 +0,0 @@
import { defineStore } from 'pinia'
export const DEFAULT_FEATURE_FLAGS = {
project_background: false,
page_path: false,
worlds_tab: false,
worlds_in_home: true,
}
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
export type FeatureFlags = Record<FeatureFlag, boolean>
export type ColorTheme = (typeof THEME_OPTIONS)[number]
export type ThemeStore = {
selectedTheme: ColorTheme
advancedRendering: boolean
toggleSidebar: boolean
devMode: boolean
featureFlags: FeatureFlags
}
export const DEFAULT_THEME_STORE: ThemeStore = {
selectedTheme: 'dark',
advancedRendering: true,
toggleSidebar: false,
devMode: false,
featureFlags: DEFAULT_FEATURE_FLAGS,
}
export const useTheming = defineStore('themeStore', {
state: () => DEFAULT_THEME_STORE,
actions: {
setThemeState(newTheme: ColorTheme) {
if (THEME_OPTIONS.includes(newTheme)) {
this.selectedTheme = newTheme
} else {
console.warn('Selected theme is not present. Check themeOptions.')
}
this.setThemeClass()
},
setThemeClass() {
for (const theme of THEME_OPTIONS) {
document.getElementsByTagName('html')[0].classList.remove(`${theme}-mode`)
}
let theme = this.selectedTheme
if (this.selectedTheme === 'system') {
const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
if (darkThemeMq.matches) {
theme = 'dark'
} else {
theme = 'light'
}
}
document.getElementsByTagName('html')[0].classList.add(`${theme}-mode`)
},
getFeatureFlag(key: FeatureFlag) {
return this.featureFlags[key] ?? DEFAULT_FEATURE_FLAGS[key]
},
getThemeOptions() {
return THEME_OPTIONS
},
},
})

View File

@@ -1,14 +1,13 @@
[package] [package]
name = "theseus_playground" name = "theseus_playground"
version = "0.0.0" version = "0.0.0"
edition.workspace = true edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
theseus = { workspace = true, features = ["cli"] } theseus = { path = "../../packages/app-lib", features = ["cli"] }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } tokio = { version = "1", features = ["full"] }
enumset.workspace = true webbrowser = "0.8.13"
[lints] tracing = "0.1.37"
workspace = true

View File

@@ -2,9 +2,9 @@
"name": "@modrinth/app-playground", "name": "@modrinth/app-playground",
"scripts": { "scripts": {
"build": "cargo build --release", "build": "cargo build --release",
"lint": "cargo fmt --check && cargo clippy --all-targets", "lint": "cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings",
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt", "fix": "cargo fmt && cargo clippy --fix",
"dev": "cargo run", "dev": "cargo run",
"test": "cargo nextest run --all-targets --no-fail-fast" "test": "cargo test"
} }
} }

View File

@@ -3,7 +3,6 @@
windows_subsystem = "windows" windows_subsystem = "windows"
)] )]
use enumset::EnumSet;
use theseus::prelude::*; use theseus::prelude::*;
use theseus::worlds::get_recent_worlds; use theseus::worlds::get_recent_worlds;
@@ -15,7 +14,8 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
println!("A browser window will now open, follow the login flow there."); println!("A browser window will now open, follow the login flow there.");
let login = minecraft_auth::begin_login().await?; let login = minecraft_auth::begin_login().await?;
println!("Open URL {} in a browser", login.redirect_uri.as_str()); println!("URL {}", login.redirect_uri.as_str());
webbrowser::open(login.redirect_uri.as_str())?;
println!("Please enter URL code: "); println!("Please enter URL code: ");
let mut input = String::new(); let mut input = String::new();
@@ -40,7 +40,7 @@ async fn main() -> theseus::Result<()> {
// Initialize state // Initialize state
State::init().await?; State::init().await?;
let worlds = get_recent_worlds(4, EnumSet::all()).await?; let worlds = get_recent_worlds(4).await?;
for world in worlds { for world in worlds {
println!( println!(
"World: {:?}/{:?} played at {:?}: {:#?}", "World: {:?}/{:?} played at {:?}: {:#?}",

View File

@@ -1,51 +1,62 @@
[package] [package]
name = "theseus_gui" name = "theseus_gui"
version = "0.9.5" version = "0.9.4"
description = "The Modrinth App is a desktop application for managing your Minecraft mods" description = "The Modrinth App is a desktop application for managing your Minecraft mods"
license = "GPL-3.0-only" license = "GPL-3.0-only"
repository = "https://github.com/modrinth/code/apps/app/" repository = "https://github.com/modrinth/code/apps/app/"
edition.workspace = true edition = "2021"
build = "build.rs"
[build-dependencies] [build-dependencies]
tauri-build = { workspace = true, features = ["codegen"] } tauri-build = { version = "2.0.3", features = ["codegen"] }
[dependencies] [dependencies]
theseus = { workspace = true, features = ["tauri"] } theseus = { path = "../../packages/app-lib", features = ["tauri"] }
serde_json.workspace = true serde_json = "1.0"
serde = { workspace = true, features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_with.workspace = true serde_with = "3.0.0"
tauri = { workspace = true, features = ["devtools", "macos-private-api", "protocol-asset"] } tauri = { version = "2.1.1", features = ["devtools", "macos-private-api", "protocol-asset", "unstable"] }
tauri-plugin-window-state.workspace = true tauri-plugin-window-state = "2.2.0"
tauri-plugin-deep-link.workspace = true tauri-plugin-deep-link = "2.2.0"
tauri-plugin-os.workspace = true tauri-plugin-os = "2.2.0"
tauri-plugin-opener.workspace = true tauri-plugin-opener = "2.2.1"
tauri-plugin-dialog.workspace = true tauri-plugin-dialog = "2.2.0"
tauri-plugin-updater.workspace = true tauri-plugin-updater = { version = "2.3.0" }
tauri-plugin-single-instance.workspace = true tauri-plugin-single-instance = { version = "2.2.0" }
tokio = { workspace = true, features = ["time"] } tokio = { version = "1", features = ["full"] }
thiserror.workspace = true thiserror = "1.0"
daedalus.workspace = true daedalus = { path = "../../packages/daedalus" }
chrono.workspace = true chrono = "0.4.26"
either.workspace = true either = "1.15"
url.workspace = true url = "2.2"
urlencoding.workspace = true urlencoding = "2.1"
uuid = { workspace = true, features = ["serde", "v4"] } uuid = { version = "1.1", features = ["serde", "v4"] }
os_info = "3.7.0"
tracing.workspace = true tracing = "0.1.37"
tracing-error.workspace = true tracing-error = "0.2.0"
dashmap.workspace = true dashmap = "6.0.1"
paste.workspace = true paste = "1.0.15"
enumset = { workspace = true, features = ["serde"] }
native-dialog.workspace = true opener = { version = "0.7.2", features = ["reveal", "dbus-vendored"] }
native-dialog = "0.7.0"
[target.'cfg(not(target_os = "linux"))'.dependencies]
window-shadows = "0.2.1"
[target.'cfg(target_os = "macos")'.dependencies]
cocoa = "0.25.0"
objc = "0.2.7"
rand = "0.8.5"
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
tauri-plugin-updater = { workspace = true, optional = true } tauri-plugin-updater = { version = "2.3.0", optional = true, features = ["native-tls-vendored", "zip"], default-features = false }
[features] [features]
# by default Tauri runs in production mode # by default Tauri runs in production mode
@@ -55,6 +66,3 @@ default = ["custom-protocol"]
# DO NOT remove this # DO NOT remove this
custom-protocol = ["tauri/custom-protocol"] custom-protocol = ["tauri/custom-protocol"]
updater = [] updater = []
[lints]
workspace = true

View File

@@ -248,7 +248,6 @@ fn main() {
"get_recent_worlds", "get_recent_worlds",
"get_profile_worlds", "get_profile_worlds",
"get_singleplayer_world", "get_singleplayer_world",
"set_world_display_status",
"rename_world", "rename_world",
"reset_world_icon", "reset_world_icon",
"backup_world", "backup_world",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -1,15 +1,15 @@
{ {
"name": "@modrinth/app", "name": "@modrinth/app",
"scripts": { "scripts": {
"tauri": "tauri",
"build": "tauri build", "build": "tauri build",
"tauri": "tauri",
"dev": "tauri dev", "dev": "tauri dev",
"test": "cargo nextest run --all-targets --no-fail-fast", "test": "cargo test",
"lint": "cargo fmt --check && cargo clippy --all-targets", "lint": "cargo fmt --check && cargo clippy --all-targets -- -D warnings",
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt" "fix": "cargo fmt && cargo clippy --fix"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "2.5.0" "@tauri-apps/cli": "2.1.0"
}, },
"dependencies": { "dependencies": {
"@modrinth/app-frontend": "workspace:*", "@modrinth/app-frontend": "workspace:*",

View File

@@ -1,6 +1,4 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::Runtime;
use tauri_plugin_opener::OpenerExt;
use theseus::{ use theseus::{
handler, handler,
prelude::{CommandPayload, DirectoryInfo}, prelude::{CommandPayload, DirectoryInfo},
@@ -37,7 +35,6 @@ pub fn get_os() -> OS {
let os = OS::MacOS; let os = OS::MacOS;
os os
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(clippy::enum_variant_names)] #[allow(clippy::enum_variant_names)]
pub enum OS { pub enum OS {
@@ -50,56 +47,57 @@ pub enum OS {
// Create a new HashMap with the same keys // Create a new HashMap with the same keys
// Values provided should not be used directly, as they are not guaranteed to be up-to-date // Values provided should not be used directly, as they are not guaranteed to be up-to-date
#[tauri::command] #[tauri::command]
pub async fn progress_bars_list() pub async fn progress_bars_list(
-> Result<DashMap<uuid::Uuid, theseus::LoadingBar>> { ) -> Result<DashMap<uuid::Uuid, theseus::LoadingBar>> {
let res = theseus::EventState::list_progress_bars().await?; let res = theseus::EventState::list_progress_bars().await?;
Ok(res) Ok(res)
} }
// cfg only on mac os
// disables mouseover and fixes a random crash error only fixed by recent versions of macos // disables mouseover and fixes a random crash error only fixed by recent versions of macos
#[cfg(target_os = "macos")]
#[tauri::command] #[tauri::command]
pub async fn should_disable_mouseover() -> bool { pub async fn should_disable_mouseover() -> bool {
if cfg!(target_os = "macos") { // We try to match version to 12.2 or higher. If unrecognizable to pattern or lower, we default to the css with disabled mouseover for safety
// We try to match version to 12.2 or higher. If unrecognizable to pattern or lower, we default to the css with disabled mouseover for safety let os = os_info::get();
if let tauri_plugin_os::Version::Semantic(major, minor, _) = if let os_info::Version::Semantic(major, minor, _) = os.version() {
tauri_plugin_os::version() if *major >= 12 && *minor >= 3 {
{ // Mac os version is 12.3 or higher, we allow mouseover
if major >= 12 && minor >= 3 { return false;
// Mac os version is 12.3 or higher, we allow mouseover
return false;
}
} }
true
} else {
// Not macos, we allow mouseover
false
} }
true
}
#[cfg(not(target_os = "macos"))]
#[tauri::command]
pub async fn should_disable_mouseover() -> bool {
false
} }
#[tauri::command] #[tauri::command]
pub fn highlight_in_folder<R: Runtime>( pub fn highlight_in_folder(path: PathBuf) {
app: tauri::AppHandle<R>, let res = opener::reveal(path);
path: PathBuf,
) { if let Err(e) = res {
if let Err(e) = app.opener().reveal_item_in_dir(path) {
tracing::error!("Failed to highlight file in folder: {}", e); tracing::error!("Failed to highlight file in folder: {}", e);
} }
} }
#[tauri::command] #[tauri::command]
pub fn open_path<R: Runtime>(app: tauri::AppHandle<R>, path: PathBuf) { pub fn open_path(path: PathBuf) {
if let Err(e) = app.opener().open_path(path.to_string_lossy(), None::<&str>) let res = opener::open(path);
{
if let Err(e) = res {
tracing::error!("Failed to open path: {}", e); tracing::error!("Failed to open path: {}", e);
} }
} }
#[tauri::command] #[tauri::command]
pub fn show_launcher_logs_folder<R: Runtime>(app: tauri::AppHandle<R>) { pub fn show_launcher_logs_folder() {
let path = DirectoryInfo::launcher_logs_dir().unwrap_or_default(); let path = DirectoryInfo::launcher_logs_dir().unwrap_or_default();
// failure to get folder just opens filesystem // failure to get folder just opens filesystem
// (ie: if in debug mode only and launcher_logs never created) // (ie: if in debug mode only and launcher_logs never created)
open_path(app, path); open_path(path);
} }
// Get opening command // Get opening command

View File

@@ -1,12 +1,10 @@
use crate::api::Result; use crate::api::Result;
use either::Either; use either::Either;
use enumset::EnumSet;
use tauri::{AppHandle, Manager, Runtime}; use tauri::{AppHandle, Manager, Runtime};
use theseus::prelude::ProcessMetadata; use theseus::prelude::ProcessMetadata;
use theseus::profile::{QuickPlayType, get_full_path}; use theseus::profile::{get_full_path, QuickPlayType};
use theseus::worlds::{ use theseus::worlds::{
DisplayStatus, ServerPackStatus, ServerStatus, World, WorldType, ServerPackStatus, ServerStatus, World, WorldWithProfile,
WorldWithProfile,
}; };
use theseus::{profile, worlds}; use theseus::{profile, worlds};
@@ -16,7 +14,6 @@ pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
get_recent_worlds, get_recent_worlds,
get_profile_worlds, get_profile_worlds,
get_singleplayer_world, get_singleplayer_world,
set_world_display_status,
rename_world, rename_world,
reset_world_icon, reset_world_icon,
backup_world, backup_world,
@@ -36,14 +33,9 @@ pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
pub async fn get_recent_worlds<R: Runtime>( pub async fn get_recent_worlds<R: Runtime>(
app_handle: AppHandle<R>, app_handle: AppHandle<R>,
limit: usize, limit: usize,
display_statuses: Option<EnumSet<DisplayStatus>>,
) -> Result<Vec<WorldWithProfile>> { ) -> Result<Vec<WorldWithProfile>> {
let mut result = worlds::get_recent_worlds( let mut result = worlds::get_recent_worlds(limit).await?;
limit, for world in result.iter_mut() {
display_statuses.unwrap_or(EnumSet::all()),
)
.await?;
for world in &mut result {
adapt_world_icon(&app_handle, &mut world.world); adapt_world_icon(&app_handle, &mut world.world);
} }
Ok(result) Ok(result)
@@ -55,7 +47,7 @@ pub async fn get_profile_worlds<R: Runtime>(
path: &str, path: &str,
) -> Result<Vec<World>> { ) -> Result<Vec<World>> {
let mut result = worlds::get_profile_worlds(path).await?; let mut result = worlds::get_profile_worlds(path).await?;
for world in &mut result { for world in result.iter_mut() {
adapt_world_icon(&app_handle, world); adapt_world_icon(&app_handle, world);
} }
Ok(result) Ok(result)
@@ -67,7 +59,8 @@ pub async fn get_singleplayer_world<R: Runtime>(
instance: &str, instance: &str,
world: &str, world: &str,
) -> Result<World> { ) -> Result<World> {
let mut world = worlds::get_singleplayer_world(instance, world).await?; let instance = get_full_path(instance).await?;
let mut world = worlds::get_singleplayer_world(&instance, world).await?;
adapt_world_icon(&app_handle, &mut world); adapt_world_icon(&app_handle, &mut world);
Ok(world) Ok(world)
} }
@@ -97,22 +90,6 @@ fn adapt_world_icon<R: Runtime>(app_handle: &AppHandle<R>, world: &mut World) {
} }
} }
#[tauri::command]
pub async fn set_world_display_status(
instance: &str,
world_type: WorldType,
world_id: &str,
display_status: DisplayStatus,
) -> Result<()> {
Ok(worlds::set_world_display_status(
instance,
world_type,
world_id,
display_status,
)
.await?)
}
#[tauri::command] #[tauri::command]
pub async fn rename_world( pub async fn rename_world(
instance: &str, instance: &str,

View File

@@ -11,8 +11,7 @@ pub fn get_or_init_payload<R: Runtime, M: Manager<R>>(
manager: &M, manager: &M,
) -> InitialPayload { ) -> InitialPayload {
let initial_payload = manager.try_state::<InitialPayload>(); let initial_payload = manager.try_state::<InitialPayload>();
let mtx = if let Some(initial_payload) = initial_payload {
if let Some(initial_payload) = initial_payload {
initial_payload.inner().clone() initial_payload.inner().clone()
} else { } else {
tracing::info!("No initial payload found, creating new"); tracing::info!("No initial payload found, creating new");
@@ -23,5 +22,7 @@ pub fn get_or_init_payload<R: Runtime, M: Manager<R>>(
manager.manage(payload.clone()); manager.manage(payload.clone());
payload payload
} };
mtx
} }

View File

@@ -1 +1,2 @@
pub mod deep_link; pub mod deep_link;
pub mod window_ext;

View File

@@ -0,0 +1,412 @@
// Stolen from https://gist.github.com/charrondev/43150e940bd2771b1ea88256d491c7a9
use objc::{msg_send, sel, sel_impl};
use rand::{distributions::Alphanumeric, Rng};
use tauri::{
plugin::{Builder, TauriPlugin},
Emitter, Runtime, Window,
}; // 0.8
const WINDOW_CONTROL_PAD_X: f64 = 9.0;
const WINDOW_CONTROL_PAD_Y: f64 = 10.0;
struct UnsafeWindowHandle(*mut std::ffi::c_void);
unsafe impl Send for UnsafeWindowHandle {}
unsafe impl Sync for UnsafeWindowHandle {}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("traffic_light_positioner")
.on_window_ready(|window| {
#[cfg(target_os = "macos")]
setup_traffic_light_positioner(window);
})
.build()
}
#[cfg(target_os = "macos")]
fn position_traffic_lights(
ns_window_handle: UnsafeWindowHandle,
x: f64,
y: f64,
) {
use cocoa::appkit::{NSView, NSWindow, NSWindowButton};
use cocoa::foundation::NSRect;
let ns_window = ns_window_handle.0 as cocoa::base::id;
unsafe {
let close = ns_window
.standardWindowButton_(NSWindowButton::NSWindowCloseButton);
let miniaturize = ns_window
.standardWindowButton_(NSWindowButton::NSWindowMiniaturizeButton);
let zoom =
ns_window.standardWindowButton_(NSWindowButton::NSWindowZoomButton);
let title_bar_container_view = close.superview().superview();
let close_rect: NSRect = msg_send![close, frame];
let button_height = close_rect.size.height + 12.0;
let title_bar_frame_height = button_height + y;
let mut title_bar_rect = NSView::frame(title_bar_container_view);
title_bar_rect.size.height = title_bar_frame_height;
title_bar_rect.origin.y =
NSView::frame(ns_window).size.height - title_bar_frame_height;
let _: () =
msg_send![title_bar_container_view, setFrame: title_bar_rect];
let window_buttons = vec![close, miniaturize, zoom];
let space_between =
NSView::frame(miniaturize).origin.x - NSView::frame(close).origin.x;
for (i, button) in window_buttons.into_iter().enumerate() {
let mut rect: NSRect = NSView::frame(button);
rect.origin.x = x + (i as f64 * space_between) + 6.0;
button.setFrameOrigin(rect.origin);
}
}
}
#[cfg(target_os = "macos")]
#[derive(Debug)]
struct WindowState<R: Runtime> {
window: Window<R>,
}
#[cfg(target_os = "macos")]
pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
use cocoa::appkit::NSWindow;
use cocoa::base::{id, BOOL};
use cocoa::foundation::NSUInteger;
use objc::runtime::{Object, Sel};
use std::ffi::c_void;
// Do the initial positioning
position_traffic_lights(
UnsafeWindowHandle(
window.ns_window().expect("Failed to create window handle"),
),
WINDOW_CONTROL_PAD_X,
WINDOW_CONTROL_PAD_Y,
);
// Ensure they stay in place while resizing the window.
fn with_window_state<R: Runtime, F: FnOnce(&mut WindowState<R>) -> T, T>(
this: &Object,
func: F,
) {
let ptr = unsafe {
let x: *mut c_void = *this.get_ivar("app_box");
&mut *(x as *mut WindowState<R>)
};
func(ptr);
}
unsafe {
let ns_win = window
.ns_window()
.expect("NS Window should exist to mount traffic light delegate.")
as id;
let current_delegate: id = ns_win.delegate();
extern "C" fn on_window_should_close(
this: &Object,
_cmd: Sel,
sender: id,
) -> BOOL {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
msg_send![super_del, windowShouldClose: sender]
}
}
extern "C" fn on_window_will_close(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, windowWillClose: notification];
}
}
extern "C" fn on_window_did_resize<R: Runtime>(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
with_window_state(this, |state: &mut WindowState<R>| {
let id = state.window.ns_window().expect(
"NS window should exist on state to handle resize",
) as id;
#[cfg(target_os = "macos")]
position_traffic_lights(
UnsafeWindowHandle(id as *mut std::ffi::c_void),
WINDOW_CONTROL_PAD_X,
WINDOW_CONTROL_PAD_Y,
);
});
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, windowDidResize: notification];
}
}
extern "C" fn on_window_did_move(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, windowDidMove: notification];
}
}
extern "C" fn on_window_did_change_backing_properties(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, windowDidChangeBackingProperties: notification];
}
}
extern "C" fn on_window_did_become_key(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () =
msg_send![super_del, windowDidBecomeKey: notification];
}
}
extern "C" fn on_window_did_resign_key(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () =
msg_send![super_del, windowDidResignKey: notification];
}
}
extern "C" fn on_dragging_entered(
this: &Object,
_cmd: Sel,
notification: id,
) -> BOOL {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
msg_send![super_del, draggingEntered: notification]
}
}
extern "C" fn on_prepare_for_drag_operation(
this: &Object,
_cmd: Sel,
notification: id,
) -> BOOL {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
msg_send![super_del, prepareForDragOperation: notification]
}
}
extern "C" fn on_perform_drag_operation(
this: &Object,
_cmd: Sel,
sender: id,
) -> BOOL {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
msg_send![super_del, performDragOperation: sender]
}
}
extern "C" fn on_conclude_drag_operation(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () =
msg_send![super_del, concludeDragOperation: notification];
}
}
extern "C" fn on_dragging_exited(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, draggingExited: notification];
}
}
extern "C" fn on_window_will_use_full_screen_presentation_options(
this: &Object,
_cmd: Sel,
window: id,
proposed_options: NSUInteger,
) -> NSUInteger {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
msg_send![super_del, window: window willUseFullScreenPresentationOptions: proposed_options]
}
}
extern "C" fn on_window_did_enter_full_screen<R: Runtime>(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
with_window_state(this, |state: &mut WindowState<R>| {
state
.window
.emit("did-enter-fullscreen", ())
.expect("Failed to emit event");
});
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, windowDidEnterFullScreen: notification];
}
}
extern "C" fn on_window_will_enter_full_screen<R: Runtime>(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
with_window_state(this, |state: &mut WindowState<R>| {
state
.window
.emit("will-enter-fullscreen", ())
.expect("Failed to emit event");
});
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, windowWillEnterFullScreen: notification];
}
}
extern "C" fn on_window_did_exit_full_screen<R: Runtime>(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
with_window_state(this, |state: &mut WindowState<R>| {
state
.window
.emit("did-exit-fullscreen", ())
.expect("Failed to emit event");
let id =
state.window.ns_window().expect("Failed to emit event")
as id;
position_traffic_lights(
UnsafeWindowHandle(id as *mut std::ffi::c_void),
WINDOW_CONTROL_PAD_X,
WINDOW_CONTROL_PAD_Y,
);
});
let super_del: id = *this.get_ivar("super_delegate");
let _: () =
msg_send![super_del, windowDidExitFullScreen: notification];
}
}
extern "C" fn on_window_will_exit_full_screen<R: Runtime>(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
with_window_state(this, |state: &mut WindowState<R>| {
state
.window
.emit("will-exit-fullscreen", ())
.expect("Failed to emit event");
});
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, windowWillExitFullScreen: notification];
}
}
extern "C" fn on_window_did_fail_to_enter_full_screen(
this: &Object,
_cmd: Sel,
window: id,
) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, windowDidFailToEnterFullScreen: window];
}
}
extern "C" fn on_effective_appearance_did_change(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, effectiveAppearanceDidChange: notification];
}
}
extern "C" fn on_effective_appearance_did_changed_on_main_thread(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![
super_del,
effectiveAppearanceDidChangedOnMainThread: notification
];
}
}
// Are we deallocing this properly ? (I miss safe Rust :( )
let window_label = window.label().to_string();
let app_state = WindowState { window };
let app_box = Box::into_raw(Box::new(app_state)) as *mut c_void;
let random_str: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(20)
.map(char::from)
.collect();
// We need to ensure we have a unique delegate name, otherwise we will panic while trying to create a duplicate
// delegate with the same name.
let delegate_name =
format!("windowDelegate_{}_{}", window_label, random_str);
ns_win.setDelegate_(delegate!(&delegate_name, {
window: id = ns_win,
app_box: *mut c_void = app_box,
toolbar: id = cocoa::base::nil,
super_delegate: id = current_delegate,
(windowShouldClose:) => on_window_should_close as extern fn(&Object, Sel, id) -> BOOL,
(windowWillClose:) => on_window_will_close as extern fn(&Object, Sel, id),
(windowDidResize:) => on_window_did_resize::<R> as extern fn(&Object, Sel, id),
(windowDidMove:) => on_window_did_move as extern fn(&Object, Sel, id),
(windowDidChangeBackingProperties:) => on_window_did_change_backing_properties as extern fn(&Object, Sel, id),
(windowDidBecomeKey:) => on_window_did_become_key as extern fn(&Object, Sel, id),
(windowDidResignKey:) => on_window_did_resign_key as extern fn(&Object, Sel, id),
(draggingEntered:) => on_dragging_entered as extern fn(&Object, Sel, id) -> BOOL,
(prepareForDragOperation:) => on_prepare_for_drag_operation as extern fn(&Object, Sel, id) -> BOOL,
(performDragOperation:) => on_perform_drag_operation as extern fn(&Object, Sel, id) -> BOOL,
(concludeDragOperation:) => on_conclude_drag_operation as extern fn(&Object, Sel, id),
(draggingExited:) => on_dragging_exited as extern fn(&Object, Sel, id),
(window:willUseFullScreenPresentationOptions:) => on_window_will_use_full_screen_presentation_options as extern fn(&Object, Sel, id, NSUInteger) -> NSUInteger,
(windowDidEnterFullScreen:) => on_window_did_enter_full_screen::<R> as extern fn(&Object, Sel, id),
(windowWillEnterFullScreen:) => on_window_will_enter_full_screen::<R> as extern fn(&Object, Sel, id),
(windowDidExitFullScreen:) => on_window_did_exit_full_screen::<R> as extern fn(&Object, Sel, id),
(windowWillExitFullScreen:) => on_window_will_exit_full_screen::<R> as extern fn(&Object, Sel, id),
(windowDidFailToEnterFullScreen:) => on_window_did_fail_to_enter_full_screen as extern fn(&Object, Sel, id),
(effectiveAppearanceDidChange:) => on_effective_appearance_did_change as extern fn(&Object, Sel, id),
(effectiveAppearanceDidChangedOnMainThread:) => on_effective_appearance_did_changed_on_main_thread as extern fn(&Object, Sel, id)
}))
}
}

View File

@@ -3,7 +3,7 @@
windows_subsystem = "windows" windows_subsystem = "windows"
)] )]
use native_dialog::{DialogBuilder, MessageLevel}; use native_dialog::{MessageDialog, MessageType};
use std::env; use std::env;
use tauri::{Listener, Manager}; use tauri::{Listener, Manager};
use theseus::prelude::*; use theseus::prelude::*;
@@ -14,6 +14,14 @@ mod error;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
mod macos; mod macos;
#[cfg(target_os = "macos")]
#[macro_use]
extern crate cocoa;
#[cfg(target_os = "macos")]
#[macro_use]
extern crate objc;
// Should be called in launcher initialization // Should be called in launcher initialization
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
#[tauri::command] #[tauri::command]
@@ -105,14 +113,14 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
fn show_window(app: tauri::AppHandle) { fn show_window(app: tauri::AppHandle) {
let win = app.get_window("main").unwrap(); let win = app.get_window("main").unwrap();
if let Err(e) = win.show() { if let Err(e) = win.show() {
DialogBuilder::message() MessageDialog::new()
.set_level(MessageLevel::Error) .set_type(MessageType::Error)
.set_title("Initialization error") .set_title("Initialization error")
.set_text(format!( .set_text(&format!(
"Cannot display application window due to an error:\n{e}" "Cannot display application window due to an error:\n{}",
e
)) ))
.alert() .show_alert()
.show()
.unwrap(); .unwrap();
panic!("cannot display application window") panic!("cannot display application window")
} else { } else {
@@ -130,7 +138,8 @@ fn is_dev() -> bool {
async fn toggle_decorations(b: bool, window: tauri::Window) -> api::Result<()> { async fn toggle_decorations(b: bool, window: tauri::Window) -> api::Result<()> {
window.set_decorations(b).map_err(|e| { window.set_decorations(b).map_err(|e| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!( theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Failed to toggle decorations: {e}" "Failed to toggle decorations: {}",
e
))) )))
})?; })?;
Ok(()) Ok(())
@@ -197,7 +206,7 @@ fn main() {
{ {
let payload = macos::deep_link::get_or_init_payload(app); let payload = macos::deep_link::get_or_init_payload(app);
let mtx_copy = payload.payload; let mtx_copy = payload.payload.clone();
app.listen("deep-link://new-url", move |url| { app.listen("deep-link://new-url", move |url| {
let mtx_copy_copy = mtx_copy.clone(); let mtx_copy_copy = mtx_copy.clone();
let request = url.payload().to_owned(); let request = url.payload().to_owned();
@@ -229,12 +238,13 @@ fn main() {
tauri::async_runtime::spawn(api::utils::handle_command( tauri::async_runtime::spawn(api::utils::handle_command(
payload, payload,
)); ));
dbg!(url);
}); });
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
if let Some(window) = app.get_window("main") { {
if let Err(e) = window.set_shadow(true) { if let Some(window) = app.get_window("main") {
tracing::warn!("Failed to set window shadow: {e}"); window.set_shadow(true).unwrap();
} }
} }
@@ -267,27 +277,32 @@ fn main() {
restart_app, restart_app,
]); ]);
#[cfg(target_os = "macos")]
{
builder = builder.plugin(macos::window_ext::init());
}
tracing::info!("Initializing app..."); tracing::info!("Initializing app...");
let app = builder.build(tauri::generate_context!()); let app = builder.build(tauri::generate_context!());
match app { match app {
Ok(app) => { Ok(app) => {
#[allow(unused_variables)]
app.run(|app, event| { app.run(|app, event| {
#[cfg(not(target_os = "macos"))]
drop((app, event));
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
if let tauri::RunEvent::Opened { urls } = event { if let tauri::RunEvent::Opened { urls } = event {
tracing::info!("Handling webview open {urls:?}"); tracing::info!("Handling webview open {urls:?}");
let file = urls let file = urls
.into_iter() .into_iter()
.find_map(|url| url.to_file_path().ok()); .filter_map(|url| url.to_file_path().ok())
.next();
if let Some(file) = file { if let Some(file) = file {
let payload = let payload =
macos::deep_link::get_or_init_payload(app); macos::deep_link::get_or_init_payload(app);
let mtx_copy = payload.payload; let mtx_copy = payload.payload.clone();
let request = file.to_string_lossy().to_string(); let request = file.to_string_lossy().to_string();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let mut payload = mtx_copy.lock().await; let mut payload = mtx_copy.lock().await;
@@ -305,29 +320,28 @@ fn main() {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
// tauri doesn't expose runtime errors, so matching a string representation seems like the only solution // tauri doesn't expose runtime errors, so matching a string representation seems like the only solution
if format!("{e:?}").contains( if format!("{:?}", e).contains(
"Runtime(CreateWebview(WebView2Error(WindowsError", "Runtime(CreateWebview(WebView2Error(WindowsError",
) { ) {
DialogBuilder::message() MessageDialog::new()
.set_level(MessageLevel::Error) .set_type(MessageType::Error)
.set_title("Initialization error") .set_title("Initialization error")
.set_text("Your Microsoft Edge WebView2 installation is corrupt.\n\nMicrosoft Edge WebView2 is required to run Modrinth App.\n\nLearn how to repair it at https://support.modrinth.com/en/articles/8797765-corrupted-microsoft-edge-webview2-installation") .set_text("Your Microsoft Edge WebView2 installation is corrupt.\n\nMicrosoft Edge WebView2 is required to run Modrinth App.\n\nLearn how to repair it at https://support.modrinth.com/en/articles/8797765-corrupted-microsoft-edge-webview2-installation")
.alert() .show_alert()
.show()
.unwrap(); .unwrap();
panic!("webview2 initialization failed") panic!("webview2 initialization failed")
} }
} }
DialogBuilder::message() MessageDialog::new()
.set_level(MessageLevel::Error) .set_type(MessageType::Error)
.set_title("Initialization error") .set_title("Initialization error")
.set_text(format!( .set_text(&format!(
"Cannot initialize application due to an error:\n{e:?}" "Cannot initialize application due to an error:\n{:?}",
e
)) ))
.alert() .show_alert()
.show()
.unwrap(); .unwrap();
tracing::error!("Error while running tauri application: {:?}", e); tracing::error!("Error while running tauri application: {:?}", e);

View File

@@ -1,5 +1,4 @@
{ {
"$schema": "https://schema.tauri.app/config/2",
"build": { "build": {
"beforeDevCommand": "pnpm turbo run dev --filter=@modrinth/app-frontend", "beforeDevCommand": "pnpm turbo run dev --filter=@modrinth/app-frontend",
"beforeBuildCommand": "pnpm turbo run build --filter=@modrinth/app-frontend", "beforeBuildCommand": "pnpm turbo run build --filter=@modrinth/app-frontend",
@@ -45,7 +44,7 @@
] ]
}, },
"productName": "Modrinth App", "productName": "Modrinth App",
"version": "0.9.5", "version": "0.9.4",
"mainBinaryName": "Modrinth App", "mainBinaryName": "Modrinth App",
"identifier": "ModrinthApp", "identifier": "ModrinthApp",
"plugins": { "plugins": {
@@ -77,26 +76,18 @@
], ],
"security": { "security": {
"assetProtocol": { "assetProtocol": {
"scope": [ "scope": ["$APPDATA/caches/icons/*", "$APPCONFIG/caches/icons/*", "$CONFIG/caches/icons/*", "$APPDATA/profiles/*/saves/*/icon.png", "$APPCONFIG/profiles/*/saves/*/icon.png", "$CONFIG/profiles/*/saves/*/icon.png"],
"$APPDATA/caches/icons/*",
"$APPCONFIG/caches/icons/*",
"$CONFIG/caches/icons/*",
"$APPDATA/profiles/*/saves/*/icon.png",
"$APPCONFIG/profiles/*/saves/*/icon.png",
"$CONFIG/profiles/*/saves/*/icon.png"
],
"enable": true "enable": true
}, },
"capabilities": ["ads", "core", "plugins"], "capabilities": ["ads", "core", "plugins"],
"csp": { "csp": {
"default-src": "'self' customprotocol: asset:", "default-src": "'self' customprotocol: asset:",
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs", "connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs",
"font-src": ["https://cdn-raw.modrinth.com/fonts/"], "font-src": ["https://cdn-raw.modrinth.com/fonts/inter/"],
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost blob: data:", "img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost blob: data:",
"style-src": "'unsafe-inline' 'self'", "style-src": "'unsafe-inline' 'self'",
"script-src": "https://*.posthog.com 'self'", "script-src": "https://*.posthog.com 'self'",
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self'", "frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self'"
"media-src": "https://*.githubusercontent.com"
} }
} }
} }

View File

@@ -13,11 +13,7 @@
"minWidth": 1100, "minWidth": 1100,
"visible": false, "visible": false,
"zoomHotkeysEnabled": false, "zoomHotkeysEnabled": false,
"decorations": true, "decorations": true
"trafficLightPosition": {
"x": 15.0,
"y": 22.0
}
} }
] ]
} }

View File

@@ -1,14 +0,0 @@
{
"$schema": "../../node_modules/turbo/schema.json",
"extends": ["//"],
"tasks": {
// Running Clippy and tests on a Tauri application requires
// the frontend to be built at least once first
"lint": {
"dependsOn": ["@modrinth/app-frontend#build"]
},
"test": {
"dependsOn": ["@modrinth/app-frontend#build"]
}
}
}

View File

@@ -2,32 +2,39 @@
name = "daedalus_client" name = "daedalus_client"
version = "0.2.2" version = "0.2.2"
authors = ["Jai A <jai@modrinth.com>"] authors = ["Jai A <jai@modrinth.com>"]
edition.workspace = true edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
daedalus.workspace = true daedalus = { path = "../../packages/daedalus" }
tokio = { workspace = true, features = ["sync", "macros", "rt-multi-thread"] } tokio = { version = "1", features = ["full"] }
futures.workspace = true futures = "0.3.25"
dotenvy.workspace = true dotenvy = "0.15.6"
serde = { workspace = true, features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json.workspace = true serde_json = "1.0"
serde-xml-rs.workspace = true serde-xml-rs = "0.6.0"
thiserror.workspace = true lazy_static = "1.4.0"
reqwest = { workspace = true, features = ["stream", "json", "rustls-tls-native-roots"] } thiserror = "1.0"
async_zip = { workspace = true, features = ["chrono", "tokio-fs", "deflate", "bzip2", "zstd", "deflate64"] } reqwest = { version = "0.12.5", default-features = false, features = [
chrono = { workspace = true, features = ["serde"] } "stream",
bytes.workspace = true "json",
rust-s3.workspace = true "rustls-tls-native-roots",
dashmap.workspace = true ] }
sha1_smol.workspace = true async_zip = { version = "0.0.17", features = ["full"] }
indexmap = { workspace = true, features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
itertools.workspace = true bytes = "1.6.0"
tracing-error.workspace = true rust-s3 = { version = "0.33.0", default-features = false, features = [
"fail-on-err",
"tags",
"tokio-rustls-tls",
"reqwest",
] }
dashmap = "5.5.3"
sha1_smol = { version = "1.0.0", features = ["std"] }
indexmap = { version = "2.2.6", features = ["serde"] }
itertools = "0.13.0"
tracing-error = "0.2.0"
tracing.workspace = true tracing = "0.1"
tracing-subscriber = { workspace = true, features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[lints]
workspace = true

View File

@@ -1,4 +1,4 @@
FROM rust:1.87.0 AS build FROM rust:1.82.0 as build
ENV PKG_CONFIG_ALLOW_CROSS=1 ENV PKG_CONFIG_ALLOW_CROSS=1
WORKDIR /usr/src/daedalus WORKDIR /usr/src/daedalus
@@ -9,9 +9,9 @@ RUN cargo build --release --package daedalus_client
FROM debian:bookworm-slim FROM debian:bookworm-slim
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates openssl \ && apt-get install -y --no-install-recommends ca-certificates openssl \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN update-ca-certificates RUN update-ca-certificates

View File

@@ -2,10 +2,10 @@
"name": "@modrinth/daedalus_client", "name": "@modrinth/daedalus_client",
"scripts": { "scripts": {
"build": "cargo build --release", "build": "cargo build --release",
"lint": "cargo fmt --check && cargo clippy --all-targets", "lint": "cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings",
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt", "fix": "cargo fmt && cargo clippy --fix",
"dev": "cargo run", "dev": "cargo run",
"test": "cargo nextest run --all-targets --no-fail-fast" "test": "cargo test"
}, },
"dependencies": { "dependencies": {
"@modrinth/daedalus": "workspace:*" "@modrinth/daedalus": "workspace:*"

View File

@@ -12,9 +12,7 @@ pub enum ErrorKind {
SerdeJSON(#[from] serde_json::Error), SerdeJSON(#[from] serde_json::Error),
#[error("Error while deserializing XML: {0}")] #[error("Error while deserializing XML: {0}")]
SerdeXML(#[from] serde_xml_rs::Error), SerdeXML(#[from] serde_xml_rs::Error),
#[error( #[error("Failed to validate file checksum at url {url} with hash {hash} after {tries} tries")]
"Failed to validate file checksum at url {url} with hash {hash} after {tries} tries"
)]
ChecksumFailure { ChecksumFailure {
hash: String, hash: String,
url: String, url: String,
@@ -24,7 +22,7 @@ pub enum ErrorKind {
Fetch { inner: reqwest::Error, item: String }, Fetch { inner: reqwest::Error, item: String },
#[error("Error while uploading file to S3: {file}")] #[error("Error while uploading file to S3: {file}")]
S3 { S3 {
inner: Box<s3::error::S3Error>, inner: s3::error::S3Error,
file: String, file: String,
}, },
#[error("Error acquiring semaphore: {0}")] #[error("Error acquiring semaphore: {0}")]

View File

@@ -1,6 +1,6 @@
use crate::util::{download_file, fetch_json, format_url}; use crate::util::{download_file, fetch_json, format_url};
use crate::{Error, MirrorArtifact, UploadFile, insert_mirrored_artifact}; use crate::{insert_mirrored_artifact, Error, MirrorArtifact, UploadFile};
use daedalus::modded::{DUMMY_REPLACE_STRING, Manifest, PartialVersionInfo}; use daedalus::modded::{Manifest, PartialVersionInfo, DUMMY_REPLACE_STRING};
use dashmap::DashMap; use dashmap::DashMap;
use serde::Deserialize; use serde::Deserialize;
use std::sync::Arc; use std::sync::Arc;
@@ -169,11 +169,10 @@ async fn fetch(
insert_mirrored_artifact( insert_mirrored_artifact(
&new_name, &new_name,
None, None,
vec![ vec![lib
lib.url .url
.clone() .clone()
.unwrap_or_else(|| maven_url.to_string()), .unwrap_or_else(|| maven_url.to_string())],
],
false, false,
mirror_artifacts, mirror_artifacts,
)?; )?;

View File

@@ -1,5 +1,5 @@
use crate::util::{download_file, fetch_json, fetch_xml, format_url}; use crate::util::{download_file, fetch_json, fetch_xml, format_url};
use crate::{Error, MirrorArtifact, UploadFile, insert_mirrored_artifact}; use crate::{insert_mirrored_artifact, Error, MirrorArtifact, UploadFile};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use daedalus::get_path_from_artifact; use daedalus::get_path_from_artifact;
use daedalus::modded::PartialVersionInfo; use daedalus::modded::PartialVersionInfo;
@@ -7,8 +7,8 @@ use dashmap::DashMap;
use futures::io::Cursor; use futures::io::Cursor;
use indexmap::IndexMap; use indexmap::IndexMap;
use itertools::Itertools; use itertools::Itertools;
use serde::Deserialize;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde::Deserialize;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Semaphore; use tokio::sync::Semaphore;
@@ -57,7 +57,7 @@ pub async fn fetch_forge(
ForgeVersion { ForgeVersion {
format_version, format_version,
installer_url: format!("https://maven.minecraftforge.net/net/minecraftforge/forge/{loader_version}/forge-{loader_version}-installer.jar"), installer_url: format!("https://maven.minecraftforge.net/net/minecraftforge/forge/{0}/forge-{0}-installer.jar", loader_version),
raw: loader_version, raw: loader_version,
loader_version: version_split, loader_version: version_split,
game_version: game_version.clone(), game_version: game_version.clone(),
@@ -137,7 +137,7 @@ pub async fn fetch_neo(
Ok(ForgeVersion { Ok(ForgeVersion {
format_version: 2, format_version: 2,
installer_url: format!("https://maven.neoforged.net/net/neoforged/forge/{loader_version}/forge-{loader_version}-installer.jar"), installer_url: format!("https://maven.neoforged.net/net/neoforged/forge/{0}/forge-{0}-installer.jar", loader_version),
raw: loader_version, raw: loader_version,
loader_version: version_split, loader_version: version_split,
game_version: "1.20.1".to_string(), // All NeoForge Forge versions are for 1.20.1 game_version: "1.20.1".to_string(), // All NeoForge Forge versions are for 1.20.1
@@ -163,7 +163,7 @@ pub async fn fetch_neo(
Ok(ForgeVersion { Ok(ForgeVersion {
format_version: 2, format_version: 2,
installer_url: format!("https://maven.neoforged.net/net/neoforged/neoforge/{loader_version}/neoforge-{loader_version}-installer.jar"), installer_url: format!("https://maven.neoforged.net/net/neoforged/neoforge/{0}/neoforge-{0}-installer.jar", loader_version),
loader_version: loader_version.clone(), loader_version: loader_version.clone(),
raw: loader_version, raw: loader_version,
game_version, game_version,
@@ -502,7 +502,7 @@ async fn fetch(
)?; )?;
artifact.url = artifact.url =
format_url(&format!("maven/{artifact_path}")); format_url(&format!("maven/{}", artifact_path));
return Ok(lib); return Ok(lib);
} }
@@ -589,16 +589,14 @@ async fn fetch(
mod_loader: &str, mod_loader: &str,
version: &ForgeVersion, version: &ForgeVersion,
) -> Result<String, Error> { ) -> Result<String, Error> {
let extract_file = read_file( let extract_file =
zip, read_file(zip, &value[1..value.len()])
&value[1..value.len()], .await?
) .ok_or_else(|| {
.await? crate::ErrorKind::InvalidInput(format!(
.ok_or_else(|| {
crate::ErrorKind::InvalidInput(format!(
"Unable reading data key {key} at path {value}", "Unable reading data key {key} at path {value}",
)) ))
})?; })?;
let file_name = value.split('/').next_back() let file_name = value.split('/').next_back()
.ok_or_else(|| { .ok_or_else(|| {
@@ -624,7 +622,10 @@ async fn fetch(
let path = format!( let path = format!(
"com.modrinth.daedalus:{}-installer-extracts:{}:{}@{}", "com.modrinth.daedalus:{}-installer-extracts:{}:{}@{}",
mod_loader, version.raw, file_name, ext mod_loader,
version.raw,
file_name,
ext
); );
upload_files.insert( upload_files.insert(
@@ -752,8 +753,7 @@ async fn fetch(
.rev() .rev()
.chunk_by(|x| x.game_version.clone()) .chunk_by(|x| x.game_version.clone())
.into_iter() .into_iter()
.map(|(game_version, loaders)| { .map(|(game_version, loaders)| daedalus::modded::Version {
daedalus::modded::Version {
id: game_version, id: game_version,
stable: true, stable: true,
loaders: loaders loaders: loaders
@@ -766,7 +766,6 @@ async fn fetch(
stable: false, stable: false,
}) })
.collect(), .collect(),
}
}) })
.collect(), .collect(),
}; };

View File

@@ -1,13 +1,13 @@
use crate::util::{ use crate::util::{
REQWEST_CLIENT, format_url, upload_file_to_bucket, format_url, upload_file_to_bucket, upload_url_to_bucket_mirrors,
upload_url_to_bucket_mirrors, REQWEST_CLIENT,
}; };
use daedalus::get_path_from_artifact; use daedalus::get_path_from_artifact;
use dashmap::{DashMap, DashSet}; use dashmap::{DashMap, DashSet};
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Semaphore; use tokio::sync::Semaphore;
use tracing_error::ErrorLayer; use tracing_error::ErrorLayer;
use tracing_subscriber::{EnvFilter, fmt, prelude::*}; use tracing_subscriber::{fmt, prelude::*, EnvFilter};
mod error; mod error;
mod fabric; mod fabric;

View File

@@ -1,11 +1,11 @@
use crate::util::fetch_json; use crate::util::fetch_json;
use crate::{ use crate::{
Error, MirrorArtifact, UploadFile, util::download_file, util::format_url, util::download_file, util::format_url, util::sha1_async, Error,
util::sha1_async, MirrorArtifact, UploadFile,
}; };
use daedalus::minecraft::{ use daedalus::minecraft::{
Library, PartialLibrary, VERSION_MANIFEST_URL, VersionInfo, merge_partial_library, Library, PartialLibrary, VersionInfo,
VersionManifest, merge_partial_library, VersionManifest, VERSION_MANIFEST_URL,
}; };
use dashmap::DashMap; use dashmap::DashMap;
use serde::Deserialize; use serde::Deserialize;
@@ -52,7 +52,8 @@ pub async fn fetch(
if modrinth_version if modrinth_version
.original_sha1 .original_sha1
.as_ref() .as_ref()
.is_some_and(|x| x == &version.sha1) .map(|x| x == &version.sha1)
.unwrap_or(false)
{ {
existing_versions.push(modrinth_version); existing_versions.push(modrinth_version);
} else { } else {

View File

@@ -3,57 +3,59 @@ use bytes::Bytes;
use s3::creds::Credentials; use s3::creds::Credentials;
use s3::{Bucket, Region}; use s3::{Bucket, Region};
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use std::sync::{Arc, LazyLock}; use std::sync::Arc;
use tokio::sync::Semaphore; use tokio::sync::Semaphore;
static BUCKET: LazyLock<Bucket> = LazyLock::new(|| { lazy_static::lazy_static! {
let region = dotenvy::var("S3_REGION").unwrap(); static ref BUCKET : Bucket = {
let b = Bucket::new( let region = dotenvy::var("S3_REGION").unwrap();
&dotenvy::var("S3_BUCKET_NAME").unwrap(), let b = Bucket::new(
if &*region == "r2" { &dotenvy::var("S3_BUCKET_NAME").unwrap(),
Region::R2 { if &*region == "r2" {
account_id: dotenvy::var("S3_URL").unwrap(), Region::R2 {
} account_id: dotenvy::var("S3_URL").unwrap(),
}
} else {
Region::Custom {
region: region.clone(),
endpoint: dotenvy::var("S3_URL").unwrap(),
}
},
Credentials::new(
Some(&*dotenvy::var("S3_ACCESS_TOKEN").unwrap()),
Some(&*dotenvy::var("S3_SECRET").unwrap()),
None,
None,
None,
).unwrap(),
).unwrap();
if region == "path-style" {
b.with_path_style()
} else { } else {
Region::Custom { b
region: region.clone(), }
endpoint: dotenvy::var("S3_URL").unwrap(), };
} }
},
Credentials::new(
Some(&*dotenvy::var("S3_ACCESS_TOKEN").unwrap()),
Some(&*dotenvy::var("S3_SECRET").unwrap()),
None,
None,
None,
)
.unwrap(),
)
.unwrap();
if region == "path-style" { lazy_static::lazy_static! {
*b.with_path_style() pub static ref REQWEST_CLIENT: reqwest::Client = {
} else { let mut headers = reqwest::header::HeaderMap::new();
*b if let Ok(header) = reqwest::header::HeaderValue::from_str(&format!(
} "modrinth/daedalus/{} (support@modrinth.com)",
}); env!("CARGO_PKG_VERSION")
)) {
headers.insert(reqwest::header::USER_AGENT, header);
}
pub static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| { reqwest::Client::builder()
let mut headers = reqwest::header::HeaderMap::new(); .tcp_keepalive(Some(std::time::Duration::from_secs(10)))
if let Ok(header) = reqwest::header::HeaderValue::from_str(&format!( .timeout(std::time::Duration::from_secs(15))
"modrinth/daedalus/{} (support@modrinth.com)", .default_headers(headers)
env!("CARGO_PKG_VERSION") .build()
)) { .unwrap()
headers.insert(reqwest::header::USER_AGENT, header); };
} }
reqwest::Client::builder()
.tcp_keepalive(Some(std::time::Duration::from_secs(10)))
.timeout(std::time::Duration::from_secs(15))
.default_headers(headers)
.build()
.unwrap()
});
#[tracing::instrument(skip(bytes, semaphore))] #[tracing::instrument(skip(bytes, semaphore))]
pub async fn upload_file_to_bucket( pub async fn upload_file_to_bucket(
@@ -76,7 +78,7 @@ pub async fn upload_file_to_bucket(
BUCKET.put_object(key.clone(), &bytes).await BUCKET.put_object(key.clone(), &bytes).await
} }
.map_err(|err| ErrorKind::S3 { .map_err(|err| ErrorKind::S3 {
inner: Box::new(err), inner: err,
file: path.clone(), file: path.clone(),
}); });
@@ -201,7 +203,7 @@ pub async fn download_file(
inner: err, inner: err,
item: url.to_string(), item: url.to_string(),
} }
.into()); .into())
} }
} }
} }

View File

@@ -5,8 +5,7 @@
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"start": "astro dev", "start": "astro dev",
"lint": "astro check", "build": "astro check && astro build",
"build": "astro build",
"preview": "astro preview", "preview": "astro preview",
"astro": "astro" "astro": "astro"
}, },
@@ -19,4 +18,4 @@
"starlight-openapi": "^0.14.0", "starlight-openapi": "^0.14.0",
"typescript": "^5.8.2" "typescript": "^5.8.2"
} }
} }

View File

@@ -7,7 +7,7 @@ This project is part of our [monorepo](https://github.com/modrinth/code). You ca
[labrinth] is the Rust-based backend serving Modrinth's API with the help of the [Actix](https://actix.rs) framework. To get started with a labrinth instance, install docker, docker-compose (which comes with Docker), and [Rust]. The initial startup can be done simply with the command `docker-compose up`, or with `docker compose up` (Compose V2 and later). That will deploy a PostgreSQL database on port 5432 and a MeiliSearch instance on port 7700. To run the API itself, you'll need to use the `cargo run` command, this will deploy the API on port 8000. [labrinth] is the Rust-based backend serving Modrinth's API with the help of the [Actix](https://actix.rs) framework. To get started with a labrinth instance, install docker, docker-compose (which comes with Docker), and [Rust]. The initial startup can be done simply with the command `docker-compose up`, or with `docker compose up` (Compose V2 and later). That will deploy a PostgreSQL database on port 5432 and a MeiliSearch instance on port 7700. To run the API itself, you'll need to use the `cargo run` command, this will deploy the API on port 8000.
To get a basic configuration, copy the `.env.local` file to `.env`. Now, you'll have to install the sqlx CLI, which can be done with cargo: Now, you'll have to install the sqlx CLI, which can be done with cargo:
```bash ```bash
cargo install --git https://github.com/launchbadge/sqlx sqlx-cli --no-default-features --features postgres,rustls cargo install --git https://github.com/launchbadge/sqlx sqlx-cli --no-default-features --features postgres,rustls
@@ -53,12 +53,8 @@ If you would like 'placeholder_category' to be marked as supporting modpacks too
INSERT INTO categories VALUES (0, 'placeholder_category', 2); -- modloader id, supported type id INSERT INTO categories VALUES (0, 'placeholder_category', 2); -- modloader id, supported type id
``` ```
You can find more example SQL statements for seeding the database in the `apps/labrinth/tests/files/dummy_data.sql` file.
The majority of configuration is done at runtime using [dotenvy](https://crates.io/crates/dotenvy) and the `.env` file. Each of the variables and what they do can be found in the dropdown below. Additionally, there are three command line options that can be used to specify to MeiliSearch what you want to do. The majority of configuration is done at runtime using [dotenvy](https://crates.io/crates/dotenvy) and the `.env` file. Each of the variables and what they do can be found in the dropdown below. Additionally, there are three command line options that can be used to specify to MeiliSearch what you want to do.
During development, you might notice that changes made directly to entities in the PostgreSQL database do not seem to take effect. This is often because the Redis cache still holds outdated data. To ensure your updates are reflected, clear the cache by e.g. running `redis-cli FLUSHALL`, which will force Labrinth to fetch the latest data from the database the next time it is needed.
<details> <details>
<summary>.env variables & command line options</summary> <summary>.env variables & command line options</summary>
@@ -77,18 +73,14 @@ During development, you might notice that changes made directly to entities in t
`MEILISEARCH_KEY`: The name that MeiliSearch is given `MEILISEARCH_KEY`: The name that MeiliSearch is given
`BIND_ADDR`: The bind address for the server. Supports both IPv4 and IPv6 `BIND_ADDR`: The bind address for the server. Supports both IPv4 and IPv6
`MOCK_FILE_PATH`: The path used to store uploaded files; this has no default value and will panic if unspecified `MOCK_FILE_PATH`: The path used to store uploaded files; this has no default value and will panic if unspecified
`SMTP_USERNAME`: The username used to authenticate with the SMTP server
`SMTP_PASSWORD`: The password associated with the `SMTP_USERNAME` for SMTP authentication
`SMTP_HOST`: The hostname or IP address of the SMTP server
`SMTP_PORT`: The port number on which the SMTP server is listening (commonly 25, 465, or 587)
`SMTP_TLS`: The TLS mode to use for the SMTP connection, which can be one of the following: `none`, `opportunistic_start_tls`, `requires_start_tls`, `tls`
#### CDN options #### CDN options
`STORAGE_BACKEND`: Controls what storage backend is used. This can be either `local` or `s3`, but defaults to `local` `STORAGE_BACKEND`: Controls what storage backend is used. This can be either `local`, `backblaze`, or `s3`, but defaults to `local`
The S3 configuration options are fairly self-explanatory in name, so here's simply their names: The Backblaze and S3 configuration options are fairly self-explanatory in name, so here's simply their names:
`S3_ACCESS_TOKEN`, `S3_SECRET`, `S3_URL`, `S3_REGION`, `S3_PUBLIC_BUCKET_NAME`, `S3_PRIVATE_BUCKET_NAME`, `S3_USES_PATH_STYLE_BUCKETS` `BACKBLAZE_KEY_ID`, `BACKBLAZE_KEY`, `BACKBLAZE_BUCKET_ID`
`S3_ACCESS_TOKEN`, `S3_SECRET`, `S3_URL`, `S3_REGION`, `S3_BUCKET_NAME`
#### Search, OAuth, and miscellaneous options #### Search, OAuth, and miscellaneous options

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