Compare commits
10 commits
f7e8618226
...
cee31c1e2c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cee31c1e2c | ||
|
|
5696727a50 | ||
|
|
dc32a2a80f | ||
|
|
51b708f93f | ||
|
|
d1eebbf616 | ||
|
|
01e40c1d54 | ||
|
|
5aff975993 | ||
|
|
3c7c02f3e5 | ||
|
|
8372aa28f6 | ||
|
|
9523e65826 |
9 changed files with 242 additions and 82 deletions
5
.github/workflows/desktop.yml
vendored
5
.github/workflows/desktop.yml
vendored
|
|
@ -71,7 +71,10 @@ jobs:
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
cd ui
|
cd ui
|
||||||
npm ci
|
# --ignore-scripts skips the workbox postinstall (bin/update-workbox.sh),
|
||||||
|
# a bash script that fails under cmd.exe on Windows. It's only needed
|
||||||
|
# for the PWA service worker, which the desktop app doesn't use.
|
||||||
|
npm ci --ignore-scripts
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
- name: Build Navidrome server binary
|
- name: Build Navidrome server binary
|
||||||
|
|
|
||||||
171
README.md
171
README.md
|
|
@ -1,88 +1,125 @@
|
||||||
<a href="https://www.navidrome.org"><img src="resources/logo-192x192.png" alt="Navidrome logo" title="navidrome" align="right" height="60px" /></a>
|
<img src="desktop/src-tauri/icons/128x128@2x.png" alt="Quintodrome logo" align="right" height="96" />
|
||||||
|
|
||||||
# Navidrome Music Server [](https://twitter.com/intent/tweet?text=Tired%20of%20paying%20for%20music%20subscriptions%2C%20and%20not%20finding%20what%20you%20really%20like%3F%20Roll%20your%20own%20streaming%20service%21&url=https://navidrome.org&via=navidrome)
|
# Quintodrome
|
||||||
|
|
||||||
[](https://github.com/navidrome/navidrome/releases)
|
A [Tauri](https://tauri.app) desktop app that wraps
|
||||||
[](https://nightly.link/navidrome/navidrome/workflows/pipeline/master)
|
[Navidrome](https://www.navidrome.org) — your self-hosted music server — into a
|
||||||
[](https://github.com/navidrome/navidrome/releases/latest)
|
native macOS/Windows application.
|
||||||
[](https://hub.docker.com/r/deluan/navidrome)
|
|
||||||
[](https://discord.gg/xh7j7yF)
|
|
||||||
[](https://www.reddit.com/r/navidrome/)
|
|
||||||
[](CODE_OF_CONDUCT.md)
|
|
||||||
[](https://gurubase.io/g/navidrome)
|
|
||||||
|
|
||||||
Navidrome is an open source web-based music collection server and streamer. It gives you freedom to listen to your
|
The server is bundled as a sidecar binary and managed automatically, so there's
|
||||||
music collection from any browser or mobile device. It's like your personal Spotify!
|
nothing to install, configure, or keep running: launch the app and it starts
|
||||||
|
(and later shuts down) its own Navidrome instance for you.
|
||||||
|
|
||||||
|
> This repository is a fork of [Navidrome](https://github.com/navidrome/navidrome).
|
||||||
|
> Everything outside `desktop/` is the upstream server + web UI; `desktop/` is
|
||||||
|
> the Quintodrome wrapper around it.
|
||||||
|
|
||||||
**Note**: The `master` branch may be in an unstable or even broken state during development.
|
## What it does
|
||||||
Please use [releases](https://github.com/navidrome/navidrome/releases) instead of
|
|
||||||
the `master` branch in order to get a stable set of binaries.
|
|
||||||
|
|
||||||
## [Check out our Live Demo!](https://www.navidrome.org/demo/)
|
On launch the app:
|
||||||
|
|
||||||
__Any feedback is welcome!__ If you need/want a new feature, find a bug or think of any way to improve Navidrome,
|
1. Checks whether a Navidrome server is already listening on `127.0.0.1:4533`
|
||||||
please file a [GitHub issue](https://github.com/navidrome/navidrome/issues) or join the discussion in our
|
(via its `/ping` health endpoint). If so, it reuses it as-is.
|
||||||
[Subreddit](https://www.reddit.com/r/navidrome/). If you want to contribute to the project in any other way
|
2. Otherwise it spawns the bundled `navidrome` binary as a **separate sidecar
|
||||||
([ui/backend dev](https://www.navidrome.org/docs/developers/),
|
process**, pointed at the OS app-data/music directories, and waits for it to
|
||||||
[translations](https://www.navidrome.org/docs/developers/translations/),
|
become ready.
|
||||||
[themes](https://www.navidrome.org/docs/developers/creating-themes)), please join the chat in our
|
3. Loads the Navidrome UI in the content webview, below a thin toolbar with
|
||||||
[Discord server](https://discord.gg/xh7j7yF).
|
back/forward/reload buttons and an address bar.
|
||||||
|
4. On exit, sends `SIGTERM` to the spawned server so it shuts down gracefully
|
||||||
|
(falls back to a hard kill after 3s).
|
||||||
|
|
||||||
## Installation
|
Batteries included:
|
||||||
|
|
||||||
See instructions on the [project's website](https://www.navidrome.org/docs/installation/)
|
- **Auto-provisioning** — on a fresh install the app creates the initial admin
|
||||||
|
user (`admin` / `admin`) and logs in as it automatically, skipping both the
|
||||||
|
"create an admin user" and login screens.
|
||||||
|
- **Opted-out telemetry** — Navidrome's insights collector is disabled by
|
||||||
|
default.
|
||||||
|
- **Masked address bar** — the internal `127.0.0.1:4533` origin is shown as
|
||||||
|
`http://quintodrome`.
|
||||||
|
- **External links** (Last.fm, artist pages, …) open in your default browser.
|
||||||
|
- **Native editing shortcuts** — Cmd+X/C/V/A/Z work in the web UI.
|
||||||
|
- **Swipe navigation** — two-finger swipe back/forward on macOS.
|
||||||
|
|
||||||
## Cloud Hosting
|
## Quick start
|
||||||
|
|
||||||
[PikaPods](https://www.pikapods.com) has partnered with us to offer you an
|
```sh
|
||||||
[officially supported, cloud-hosted solution](https://www.navidrome.org/docs/installation/managed/#pikapods).
|
# one-time: Node >= 24 (see .nvmrc), Go >= 1.26, Rust
|
||||||
A share of the revenue helps fund the development of Navidrome at no additional cost for you.
|
just run
|
||||||
|
```
|
||||||
|
|
||||||
[](https://www.pikapods.com/pods?run=navidrome)
|
`just run` builds the Navidrome sidecar and launches the app. No `just`? See
|
||||||
|
the [desktop README](desktop/README.md) for the plain commands, or install it
|
||||||
|
with `brew install just`.
|
||||||
|
|
||||||
## Features
|
To wipe Navidrome's persistent state (database, cache) and start fresh:
|
||||||
|
|
||||||
- Handles very **large music collections**
|
```sh
|
||||||
- Streams virtually **any audio format** available
|
just clean-state
|
||||||
- Reads and uses all your beautifully curated **metadata**
|
```
|
||||||
- Great support for **compilations** (Various Artists albums) and **box sets** (multi-disc albums)
|
|
||||||
- **Multi-user**, each user has their own play counts, playlists, favourites, etc...
|
|
||||||
- Very **low resource usage**
|
|
||||||
- **Multi-platform**, runs on macOS, Linux and Windows. **Docker** images are also provided
|
|
||||||
- Ready to use binaries for all major platforms, including **Raspberry Pi**
|
|
||||||
- Automatically **monitors your library** for changes, importing new files and reloading new metadata
|
|
||||||
- **Themeable**, modern and responsive **Web interface** based on [Material UI](https://material-ui.com)
|
|
||||||
- **Compatible** with all Subsonic/Madsonic/Airsonic [clients](https://www.navidrome.org/docs/overview/#apps)
|
|
||||||
- **Transcoding** on the fly. Can be set per user/player. **Opus encoding is supported**
|
|
||||||
- Translated to **various languages**
|
|
||||||
|
|
||||||
## Translations
|
## Building a distributable
|
||||||
|
|
||||||
Navidrome uses [POEditor](https://poeditor.com/) for translations, and we are always looking
|
```sh
|
||||||
for [more contributors](https://www.navidrome.org/docs/developers/translations/)
|
just bundle # or: ./desktop/scripts/build.sh
|
||||||
|
```
|
||||||
|
|
||||||
<a href="https://poeditor.com/">
|
This produces the platform's packaged app under
|
||||||
<img height="32" src="https://github.com/user-attachments/assets/c19b1d2b-01e1-4682-a007-12356c42147c">
|
`desktop/src-tauri/target/release/bundle/` (e.g. `macos/Quintodrome.app` on
|
||||||
</a>
|
macOS). Cross-platform binaries are built in CI — see
|
||||||
|
[`.github/workflows/desktop.yml`](.github/workflows/desktop.yml), which produces
|
||||||
|
`.app`/`.dmg` on macOS and `.exe`/`.msi` on Windows for tagged releases.
|
||||||
|
|
||||||
## Documentation
|
> Artifacts are unsigned (no Apple/Windows code-signing certs), so users will
|
||||||
All documentation can be found in the project's website: https://www.navidrome.org/docs.
|
> see Gatekeeper/SmartScreen prompts.
|
||||||
Here are some useful direct links:
|
|
||||||
|
|
||||||
- [Overview](https://www.navidrome.org/docs/overview/)
|
## Configuration
|
||||||
- [Installation](https://www.navidrome.org/docs/installation/)
|
|
||||||
- [Docker](https://www.navidrome.org/docs/installation/docker/)
|
|
||||||
- [Binaries](https://www.navidrome.org/docs/installation/pre-built-binaries/)
|
|
||||||
- [Build from source](https://www.navidrome.org/docs/installation/build-from-source/)
|
|
||||||
- [Development](https://www.navidrome.org/docs/developers/)
|
|
||||||
- [Subsonic API Compatibility](https://www.navidrome.org/docs/developers/subsonic-api/)
|
|
||||||
|
|
||||||
## Screenshots
|
Environment variables override the defaults:
|
||||||
|
|
||||||
<p align="left">
|
| Variable | Default | Purpose |
|
||||||
<img height="550" src="https://raw.githubusercontent.com/navidrome/navidrome/master/.github/screenshots/ss-mobile-login.png">
|
| --------------------------- | ------------------------------ | ---------------------------------------- |
|
||||||
<img height="550" src="https://raw.githubusercontent.com/navidrome/navidrome/master/.github/screenshots/ss-mobile-player.png">
|
| `QUINTODROME_HOST` | `127.0.0.1` | Address the webview connects to |
|
||||||
<img height="550" src="https://raw.githubusercontent.com/navidrome/navidrome/master/.github/screenshots/ss-mobile-album-view.png">
|
| `QUINTODROME_PORT` | `4533` | Port for the Navidrome server |
|
||||||
<img width="550" src="https://raw.githubusercontent.com/navidrome/navidrome/master/.github/screenshots/ss-desktop-player.png">
|
| `QUINTODROME_MUSIC_FOLDER` | OS music dir (e.g. `~/Music`) | Where your music lives |
|
||||||
</p>
|
| `QUINTODROME_ADMIN_PASSWORD`| `admin` | Password for the auto-created admin user (fresh installs only) |
|
||||||
|
| `QUINTODROME_PUBLIC_URL` | `http://quintodrome` | Friendly origin shown in the address bar |
|
||||||
|
|
||||||
|
The data folder (database, cache) is always the OS app-data directory
|
||||||
|
(e.g. `~/Library/Application Support/com.quintodrome.desktop` on macOS).
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── desktop/ # Quintodrome: the Tauri wrapper (Rust + toolbar HTML)
|
||||||
|
│ ├── src-tauri/ # sidecar spawn/health-check, lifecycle, menu, clipboard
|
||||||
|
│ ├── src/ # toolbar.html (nav bar) + index.html (splash)
|
||||||
|
│ └── scripts/ # build.sh, icon generator
|
||||||
|
├── ui/ # Navidrome web UI (React)
|
||||||
|
├── server/ # Navidrome HTTP server
|
||||||
|
├── core/ # Navidrome core (scanning, transcoding, …)
|
||||||
|
├── cmd/ # Navidrome entrypoint / CLI
|
||||||
|
└── Justfile # run / bundle / sidecar / clean-state
|
||||||
|
```
|
||||||
|
|
||||||
|
For the details of building and running the desktop app directly, see
|
||||||
|
[`desktop/README.md`](desktop/README.md).
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
|
||||||
|
- Rust (stable)
|
||||||
|
- Node.js ≥ 24 (pinned in `.nvmrc`)
|
||||||
|
- Go ≥ 1.26 (for building the Navidrome binary)
|
||||||
|
|
||||||
|
The Navidrome server is built with `make build`; the wrapper stages it as
|
||||||
|
`desktop/src-tauri/binaries/navidrome-<target-triple>` and bundles it as a Tauri
|
||||||
|
sidecar. See [`desktop/scripts/build.sh`](desktop/scripts/build.sh).
|
||||||
|
|
||||||
|
## Acknowledgements
|
||||||
|
|
||||||
|
Quintodrome is built on [Navidrome](https://github.com/navidrome/navidrome),
|
||||||
|
an open-source web-based music collection server and streamer. All credit for
|
||||||
|
the music server and web UI goes to the Navidrome project.
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@ func (pd *Queue) Shuffle() {
|
||||||
backupID = current.ID
|
backupID = current.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:gosec // playback shuffle order doesn't need cryptographic randomness
|
||||||
rand.Shuffle(len(pd.Items), func(i, j int) { pd.Items[i], pd.Items[j] = pd.Items[j], pd.Items[i] })
|
rand.Shuffle(len(pd.Items), func(i, j int) { pd.Items[i], pd.Items[j] = pd.Items[j], pd.Items[i] })
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ var _ = Describe("database backups", func() {
|
||||||
|
|
||||||
timesShuffled = make([]time.Time, len(timesDecreasingChronologically))
|
timesShuffled = make([]time.Time, len(timesDecreasingChronologically))
|
||||||
copy(timesShuffled, timesDecreasingChronologically)
|
copy(timesShuffled, timesDecreasingChronologically)
|
||||||
|
//nolint:gosec // test-only shuffle of backup times, no cryptographic requirement
|
||||||
rand.Shuffle(len(timesShuffled), func(i, j int) {
|
rand.Shuffle(len(timesShuffled), func(i, j int) {
|
||||||
timesShuffled[i], timesShuffled[j] = timesShuffled[j], timesShuffled[i]
|
timesShuffled[i], timesShuffled[j] = timesShuffled[j], timesShuffled[i]
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ func mix(a, b rgb, t float64) rgb {
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
bgTop = rgb{58, 42, 94}
|
bgTop = rgb{58, 42, 94}
|
||||||
bgBottom = rgb{23, 21, 28}
|
bgBottom = rgb{23, 21, 28}
|
||||||
circleTop = rgb{168, 85, 247}
|
circleTop = rgb{168, 85, 247}
|
||||||
circleBottom = rgb{124, 58, 237}
|
circleBottom = rgb{124, 58, 237}
|
||||||
white = rgb{255, 255, 255}
|
white = rgb{255, 255, 255}
|
||||||
|
|
@ -56,7 +56,7 @@ func sample(x, y float64) rgb {
|
||||||
}
|
}
|
||||||
|
|
||||||
// play triangle
|
// play triangle
|
||||||
tx := float64(size)*0.512
|
tx := float64(size) * 0.512
|
||||||
ax, ay := tx-float64(size)*0.145, float64(size)*0.342
|
ax, ay := tx-float64(size)*0.145, float64(size)*0.342
|
||||||
bx, by := tx-float64(size)*0.145, float64(size)*0.658
|
bx, by := tx-float64(size)*0.145, float64(size)*0.658
|
||||||
cxv, cyv := tx+float64(size)*0.165, float64(size)*0.5
|
cxv, cyv := tx+float64(size)*0.165, float64(size)*0.5
|
||||||
|
|
|
||||||
25
desktop/src-tauri/Cargo.lock
generated
25
desktop/src-tauri/Cargo.lock
generated
|
|
@ -2175,9 +2175,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2",
|
||||||
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
|
"objc2-cloud-kit",
|
||||||
|
"objc2-core-data",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
|
"objc2-core-graphics",
|
||||||
|
"objc2-core-image",
|
||||||
|
"objc2-core-text",
|
||||||
|
"objc2-core-video",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
|
"objc2-quartz-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2197,6 +2205,7 @@ version = "0.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
]
|
]
|
||||||
|
|
@ -2257,6 +2266,19 @@ dependencies = [
|
||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-video"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"objc2",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-core-graphics",
|
||||||
|
"objc2-io-surface",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-encode"
|
name = "objc2-encode"
|
||||||
version = "4.1.0"
|
version = "4.1.0"
|
||||||
|
|
@ -2718,7 +2740,10 @@ dependencies = [
|
||||||
name = "quintodrome"
|
name = "quintodrome"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"block2",
|
||||||
"libc",
|
"libc",
|
||||||
|
"objc2",
|
||||||
|
"objc2-app-kit",
|
||||||
"objc2-web-kit",
|
"objc2-web-kit",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,10 @@ thiserror = "1"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
objc2 = "0.6"
|
||||||
|
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSEvent"] }
|
||||||
objc2-web-kit = { version = "0.3", features = ["WKWebView"] }
|
objc2-web-kit = { version = "0.3", features = ["WKWebView"] }
|
||||||
|
block2 = "0.6"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# This feature is used for production builds or when `devPath` points to the
|
# This feature is used for production builds or when `devPath` points to the
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Default capability for the Quintodrome desktop window",
|
"description": "Default capability for the Quintodrome desktop window",
|
||||||
"windows": ["main", "content"],
|
"windows": ["main"],
|
||||||
"permissions": ["core:default", "core:event:default", "shell:allow-open"]
|
"permissions": ["core:default", "core:event:default", "shell:allow-open"],
|
||||||
|
"remote": {
|
||||||
|
"urls": ["http://127.0.0.1:4533", "http://localhost:4533"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,14 +26,12 @@ impl UrlMask {
|
||||||
Self { real, public }
|
Self { real, public }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `http://127.0.0.1:4533/...` -> `http://quintodrome/...`
|
|
||||||
fn mask(&self, url: &str) -> String {
|
fn mask(&self, url: &str) -> String {
|
||||||
url.strip_prefix(&self.real)
|
url.strip_prefix(&self.real)
|
||||||
.map(|rest| format!("{}{}", self.public, rest))
|
.map(|rest| format!("{}{}", self.public, rest))
|
||||||
.unwrap_or_else(|| url.to_string())
|
.unwrap_or_else(|| url.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `http://quintodrome/...` -> `http://127.0.0.1:4533/...`
|
|
||||||
fn unmask(&self, url: &str) -> String {
|
fn unmask(&self, url: &str) -> String {
|
||||||
url.strip_prefix(&self.public)
|
url.strip_prefix(&self.public)
|
||||||
.map(|rest| format!("{}{}", self.real, rest))
|
.map(|rest| format!("{}{}", self.real, rest))
|
||||||
|
|
@ -84,10 +82,6 @@ fn enable_swipe_navigation(app: &tauri::AppHandle) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Keeps the address bar in sync with the content webview's real URL.
|
/// Keeps the address bar in sync with the content webview's real URL.
|
||||||
///
|
|
||||||
/// `on_navigation` only fires for actual document loads, so history traversal
|
|
||||||
/// (back/forward) and SPA `pushState` navigation would otherwise leave the
|
|
||||||
/// address bar stale. Polling `Webview::url()` covers all of those.
|
|
||||||
fn start_url_poller(app: &tauri::AppHandle, mask: UrlMask) {
|
fn start_url_poller(app: &tauri::AppHandle, mask: UrlMask) {
|
||||||
let handle = app.clone();
|
let handle = app.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
|
|
@ -141,13 +135,103 @@ fn layout(app: &tauri::AppHandle) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Installs a local NSEvent monitor that intercepts Cmd+X/C/V/A/Z and
|
||||||
|
/// dispatches the matching native editing selector to the first responder.
|
||||||
|
///
|
||||||
|
/// Menu key equivalents don't reach the webviews (content or devtools) in
|
||||||
|
/// Tauri on macOS, so we intercept the key events at the app level instead —
|
||||||
|
/// this is the only reliable way to make cut/copy/paste work in the devtools
|
||||||
|
/// console.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn install_edit_shortcut_monitor(handle: &tauri::AppHandle) {
|
||||||
|
use objc2::runtime::{AnyObject, Sel};
|
||||||
|
use objc2::MainThreadMarker;
|
||||||
|
use objc2_app_kit::{NSApplication, NSEvent, NSEventMask, NSEventModifierFlags};
|
||||||
|
use std::ptr::NonNull;
|
||||||
|
|
||||||
|
let Some(mtm) = MainThreadMarker::new() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let app = NSApplication::sharedApplication(mtm);
|
||||||
|
let handle = handle.clone();
|
||||||
|
|
||||||
|
let block = block2::RcBlock::new(move |event: NonNull<NSEvent>| -> *mut NSEvent {
|
||||||
|
let event = unsafe { event.as_ref() };
|
||||||
|
let flags = event.modifierFlags();
|
||||||
|
if !flags.contains(NSEventModifierFlags::Command) {
|
||||||
|
return event as *const NSEvent as *mut NSEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = event
|
||||||
|
.charactersIgnoringModifiers()
|
||||||
|
.map(|s| s.to_string().to_lowercase())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let selector: Option<&'static std::ffi::CStr> = match key.as_str() {
|
||||||
|
"x" => Some(c"cut:"),
|
||||||
|
"c" => Some(c"copy:"),
|
||||||
|
"v" => Some(c"paste:"),
|
||||||
|
"a" => Some(c"selectAll:"),
|
||||||
|
"z" => {
|
||||||
|
if flags.contains(NSEventModifierFlags::Shift) {
|
||||||
|
Some(c"redo:")
|
||||||
|
} else {
|
||||||
|
Some(c"undo:")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(selector) = selector else {
|
||||||
|
return event as *const NSEvent as *mut NSEvent;
|
||||||
|
};
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let _: bool = app.sendAction_to_from(
|
||||||
|
Sel::register(selector),
|
||||||
|
None::<&AnyObject>,
|
||||||
|
None::<&AnyObject>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectAll:/undo:/redo: don't dispatch through the responder chain in
|
||||||
|
// WKWebView, so drive the content webview via execCommand too — but
|
||||||
|
// only when an editable element is actually focused, otherwise we'd
|
||||||
|
// select the whole document.
|
||||||
|
let js = match key.as_str() {
|
||||||
|
"a" => Some(
|
||||||
|
"(function(){var e=document.activeElement;if(e&&(e.tagName==='INPUT'||e.tagName==='TEXTAREA'||e.isContentEditable))document.execCommand('selectAll')})()",
|
||||||
|
),
|
||||||
|
"z" if flags.contains(NSEventModifierFlags::Shift) => {
|
||||||
|
Some("document.execCommand('redo')")
|
||||||
|
}
|
||||||
|
"z" => Some("document.execCommand('undo')"),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(js) = js {
|
||||||
|
if let Some(content) = handle.get_webview("content") {
|
||||||
|
let _ = content.eval(js);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::ptr::null_mut()
|
||||||
|
});
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let monitor =
|
||||||
|
NSEvent::addLocalMonitorForEventsMatchingMask_handler(NSEventMask::KeyDown, &block);
|
||||||
|
if let Some(monitor) = monitor {
|
||||||
|
std::mem::forget(monitor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
// The default menu wires up the Edit items (undo/cut/copy/paste/select
|
// Native menu so Edit > Cut/Copy/Paste work via clicks.
|
||||||
// all), so Cmd+X/C/V/A/Z work in the webview's text fields.
|
|
||||||
.menu(|handle| tauri::menu::Menu::default(handle))
|
.menu(|handle| tauri::menu::Menu::default(handle))
|
||||||
.manage(ServerState::default())
|
.manage(ServerState::default())
|
||||||
.invoke_handler(tauri::generate_handler![back, forward, reload, navigate])
|
.invoke_handler(tauri::generate_handler![back, forward, reload, navigate])
|
||||||
|
|
@ -201,6 +285,9 @@ pub fn run() {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
enable_swipe_navigation(app.handle());
|
enable_swipe_navigation(app.handle());
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
install_edit_shortcut_monitor(app.handle());
|
||||||
|
|
||||||
if let Some(toolbar) = app.get_webview("main") {
|
if let Some(toolbar) = app.get_webview("main") {
|
||||||
let _ = toolbar.emit("url-changed", mask.mask(&navidrome_url));
|
let _ = toolbar.emit("url-changed", mask.mask(&navidrome_url));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue