diff --git a/docs/SERVER.md b/docs/SERVER.md index 3cb5300..d7db33a 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -11,8 +11,9 @@ Each release has: - **`RadioMaster-Server--windows-x64.zip`** — `RadioMaster-Server.exe` and this guide. ## Set it up -Run the setup once. It asks for the station name, the **username and password** desktop apps sign in with -(at least 12 characters), and the port (9259 unless you choose another): +Run the setup once. It asks for the station name, the **username and password** of the first administrator +(at least 12 characters; more accounts are added from the app, see [Accounts](#accounts-and-permissions)), +and the port (9259 unless you choose another): ``` RadioMaster-Server --setup @@ -36,8 +37,8 @@ encoders. Run `RadioMaster-Server --help` for every option. | Option | What it does | |---|---| -| `--setup` | set the station name, username, password and port, then exit | -| `--user`, `--password`, `--name` | answers for `--setup` | +| `--setup` | set the station name, an administrator's username and password, and the port, then exit | +| `--user`, `--password`, `--name` | answers for `--setup` (running it again with another `--user` adds that administrator; with the same one, it sets a new password) | | `--port N`, `--bind ADDRESS` | where to listen (default `0.0.0.0:9259`) | | `--data FOLDER` | the data folder (default `~/.config/RadioMaster-Server`, or `%APPDATA%\RadioMaster-Server` on Windows) | | `--https`, `--no-https` | serve over HTTPS (see [HTTPS](#https)) | @@ -46,6 +47,7 @@ encoders. Run `RadioMaster-Server --help` for every option. | `--portal`, `--no-portal`, `--portal-port N` | the [web portal](#web-portal) and its port (default 9260) | | `--tunnel`, `--no-tunnel`, `--tunnel-token TOKEN`, `--cloudflared FILE` | put the portal on the internet with a [Cloudflare Tunnel](#cloudflare-tunnel) | | `--no-audio` | don't use a sound card: stream only | +| `--no-update-check` | don't look for newer releases on GitHub | | `--list-devices` | list the sound cards | With `--setup` these options are saved in `server.json`; without it they apply to that run only. @@ -60,11 +62,42 @@ switches "Play through the server's sound cards" on or off and ticks which cards effect at once and is shown with each card's status. With no sound card (or with it switched off, or with `--no-audio`) the server runs on a silent clock, so automation and streaming carry on as normal. -**File → Server settings…** also changes the station name, whether the server starts playing when it starts, -and the server password (you type the current one first). +**File → Server settings…** (administrators only) also changes the station name, whether the server starts +playing when it starts, and your own password (you type the current one first). + +## Accounts and permissions +The server has its own accounts, kept in `operators.json` in its data folder (a server set up before v0.2.9 +turns its one account into the first administrator). In the app, an administrator opens **File → Server +users…** to add accounts, set or reset passwords, disable or delete them, and choose what each may do: the same +permissions and per-tool access as the app's own Users window (playback, jingles, editing playlists, streaming, +settings, and tools such as the generator, adverts, scheduler, track editor and file explorer). + +The server checks every action against the account that sent it, so a presenter account can play and edit +playlists but can't, say, change the station's settings or start encoders, whatever app or browser it uses; +a change it isn't allowed to make is refused and the app shows the server's version again. Only +administrators change the server's own settings, its accounts, and update, restart or stop it. A disabled or +deleted account is signed out straight away. While connected, the app shows only what both your own app +account and your server account allow. + +## Updating, restarting and stopping +The server looks for a newer release on GitHub at start-up and every six hours (`--no-update-check` turns this +off). When it is behind (or older than the app connecting to it), the app shows a blue **⬆ Server** button next +to the connection button, like its own update button, and opens the update window once for administrators. +**⬇ Update the server** downloads the RadioMaster Server package for the server's system (Linux x64 or Windows +x64), puts the new program in place of the old one and restarts; the apps reconnect by themselves after a few +seconds of silence. **Restart** and **Stop** are there too (and under **File → Server info…**), each asking +once more before it goes ahead. A stopped server stays off air until it is started again on that computer. + +- Under **systemd** the server leaves the restart to the service (`Restart=on-failure` in the unit). The + service account must be allowed to replace the program: install it in a folder that account owns, as the + unit file shows (`/opt/radiomaster`). If it can't, the update says so and changes nothing. +- Under **NSSM** or another service manager that starts the program again when it ends, set + `RADIOMASTER_SUPERVISED=1` in its environment. Otherwise (a console, Task Scheduler) the server starts a new + copy of itself. ### Run it as a service **Linux (systemd):** the package includes `radiomaster-server.service`; the steps are at the top of that file. +They put the program in `/opt/radiomaster`, owned by the service account, so it can update itself. `journalctl -u radiomaster-server -f` shows the station log. **Windows Server:** run `RadioMaster-Server.exe --setup` once in a console, then start it at boot, for @@ -144,7 +177,7 @@ or off in Server settings takes effect at once. The desktop app can connect thro The web portal runs the station from a browser on any computer, tablet or phone, with nothing to install. Turn it on with `--setup --portal` (choose the port with `--portal-port`, default 9260), or in the app with **File → Server settings… → Web portal** (after a restart). Open `http://:9260` (or `https://` with -HTTPS on) and sign in with the server's username and password. +HTTPS on) and sign in with a server account; it can do what that account may do. It works on phones (the sections sit along the bottom of the screen) as well as computers: @@ -193,15 +226,31 @@ station's mic settings (lower, mute or pause the music); Studio monitor outputs it is on. Wear headphones rather than speakers, and expect to hear your own voice come back slightly late. Things that stay on your computer: the interface language, sound cards, the remote-control API, plugins, -update checks and ffmpeg. The music files, folders and library are the server's, so a library folder is typed -as the server sees it (e.g. `/srv/music`). Previews and waveforms in the fade editor need the files on your own -computer and don't work while connected. +update checks and ffmpeg. + +**Files on the server:** the music files, folders and library are the server's. While connected, every place +that picks a file or folder (adding files or a folder to a playlist, library folders, generator categories, the +jingle cart wall, adverts and rules, events, playlists to open or insert, sounds for speech rows…) shows the +server's folders instead of your computer's. **Upload from this computer…** in that browser copies files from +your computer into the folder shown; files dragged onto the playlist from your computer, and recorded voice +tracks, are uploaded the same way (you choose the folder). The **File explorer** (a panel, or its own window +from **Tools → File explorer**) browses the server's folders too, and **All files** shows every file, not only +audio. **Reports** use the server's play history. **Library cleanup** works on the files of the computer it +runs on, so it is only available when you're not connected. Accounts that aren't administrators (or allowed +to change settings) only see, open and upload into the server's library folders. + +**Station profiles onto the server:** with **File → Station profiles…** while connected, **Load** puts a +profile's settings on the server (and, if you tick it, the playlists saved with it). The profile's folders are +listed so you can say where each one is on the server (type it or browse with **…**); every path under it is +changed to match, for example `C:\Users\DJ\Music\Jingles\id.mp3` → `/srv/music/Jingles/id.mp3`. The server +keeps its own sound cards and other machine settings. **Import…** adds a profile file from your computer. ## Security -The username and password are checked with Argon2 (only the hash is stored, in `server.json`), sign-ins -expire after a day without use, and repeated wrong passwords are slowed down. Whoever signs in can do everything the station can, including playlist -`RUN` commands that start programs on the server, so use a strong password and run the server under its own -unprivileged account (the systemd unit does). On a network you don't trust, or over the internet, turn on +Passwords are checked with Argon2 (only the hashes are stored, in `operators.json`), sign-ins expire after a +day without use, and repeated wrong passwords are slowed down. Each account can do only what its permissions +allow, but an account that may edit playlists can add `RUN` commands that start programs on the server, so give +that only to people you trust, use strong passwords and run the server under its own unprivileged account (the +systemd unit does). Non-administrators can only download, browse and upload files in the library folders. On a network you don't trust, or over the internet, turn on [HTTPS](#https) (or put the server behind an HTTPS reverse proxy such as Caddy or nginx, or a VPN). Without it the password and everything else go over the network unencrypted. The web portal uses the same sign-in, and its pages refuse to be shown inside other sites. With nginx, switch response buffering off diff --git a/lang/template.json b/lang/template.json index f3474f4..92ef939 100644 --- a/lang/template.json +++ b/lang/template.json @@ -16,6 +16,7 @@ "A different pattern for part of the day. The first one that covers the time is used; outside them the pattern above is.": "", "A newer RadioMaster is on GitHub — click to see what's new and install it": "", "A plugin controls RadioMaster by printing lines such as RM: VOLUME 60 or RM: PLAYLIST Evening.": "", + "A stopped server is off air until someone starts it again on that computer.": "", "AAC (ffmpeg)": "", "ADVERT": "", "API key": "", @@ -30,21 +31,24 @@ "Add Track List…": "", "Add URL…": "", "Add a key": "", + "Add a profile file (.json) from this computer": "", "Add a tab to this panel": "", "Add a user": "", "Add all": "", "Add all shown tracks to the playlist": "", "Add an API key on the Connection page to skip songs, restart services, manage queue, requests, playlists, media, DJs and listeners.": "", - "Add and rescan": "", "Add command…": "", "Add comment…": "", "Add container…": "", "Add event…": "", + "Add files": "", "Add files…": "", "Add file…": "", + "Add folder": "", "Add folder to playlist": "", "Add folder…": "", "Add hour marker…": "", + "Add library folder": "", "Add library folder…": "", "Add teaser…": "", "Add text to speech…": "", @@ -69,9 +73,11 @@ "Album": "", "All": "", "All artists": "", + "All files": "", "All genres": "", "All tools": "", "Also appended to history.csv in the RadioMaster data folder.": "", + "Also load its playlists (they replace the server's)": "", "Also the same song in different files": "", "Another folder to look for moved files in (besides the library folders)": "", "Another running order from the same template": "", @@ -83,6 +89,8 @@ "Apply ticked": "", "Artist": "", "Artist separation": "", + "Asking the server…": "", + "Assign audio file": "", "Assign audio file…": "", "At": "", "At the top of every hour": "", @@ -115,10 +123,11 @@ "Cart wall": "", "Categories": "", "Categories (what each pool of tracks is made from)": "", + "Category folder": "", "Certificate SHA-256:": "", "Certificate valid until": "", - "Change the server password": "", "Change ticked to": "", + "Change your password": "", "Check for updates when RadioMaster starts": "", "Check for updates…": "", "Check that this fingerprint is the one RadioMaster Server printed when it started (also in its Server settings). It is remembered for this server.": "", @@ -126,7 +135,12 @@ "Checking GitHub for a newer version…": "", "Checking files": "", "Checks every library track and playlist row: files that have gone (and where they may have moved to) and files that can't be played.": "", + "Choose": "", + "Choose a file": "", "Choose a folder": "", + "Choose files": "", + "Choose the folder on the server": "", + "Choose this folder": "", "Choose…": "", "Clear": "", "Clear all pending": "", @@ -153,12 +167,15 @@ "Connect to Server…": "", "Connect without saving": "", "Connected to a server: Generate makes a new running order there.": "", + "Connected: Load puts a profile's settings (and, if you like, its playlists) on the server, with its folders mapped to the server's.": "", "Connection": "", "Content type": "", "Copies host + server type into the first encoder": "", + "Copy files from this computer into this folder on the server": "", "Copy to playlist": "", "Create administrator": "", "Create user": "", + "Ctrl+click to select several files.": "", "Cued": "", "Current password": "", "Custom encoder (your command)": "", @@ -182,6 +199,7 @@ "Do": "", "Do it now, as a test": "", "Domain names": "", + "Double-click a file, or select it and choose it.": "", "Double-click a folder to open it, or a file to add it to the playlist you're viewing.": "", "Download it and add it to the playlist you're viewing": "", "Download it and put it after the selected row": "", @@ -230,9 +248,10 @@ "Fade out every jingle that is playing (Ctrl+J)": "", "File": "", "File Explorer": "", + "File explorer": "", "Files": "", "Files that can't be played": "", - "Files, folders and the library are the server's. Previews and waveforms need the files on this computer, so they don't work while connected. The Mic button sends this computer's microphone to the server, which puts it on air.": "", + "Files, folders and the library are the server's: file pickers and the file explorer show the server's folders, and Upload copies files from this computer to it. The Mic button sends this computer's microphone to the server, which puts it on air.": "", "Fill encoder 1 with this DJ's login (type the password there)": "", "Fill in the artist from the title for these tracks?": "", "Filter": "", @@ -243,6 +262,8 @@ "Fixed crossfade": "", "Fixed keys": "", "Flat": "", + "Folder in the profile": "", + "Folder on the server": "", "Folder…": "", "Follow": "", "Follow each screen's display scaling": "", @@ -283,6 +304,7 @@ "If nobody changed the server's certificate, someone may be listening in. Don't trust it unless you're sure.": "", "If the sound crackles or stutters on a slow or busy computer, pick a bigger buffer. A bigger buffer adds a little delay between pressing Play and hearing it.": "", "If you don't save, the server's playlists and settings stay in this app after you disconnect.": "", + "Import…": "", "In AzuraCast: your account (top right) → API Keys → Add API key. Paste it on the 🔑 Connection page.": "", "Input": "", "Input (microphone / line in)": "", @@ -292,7 +314,9 @@ "Insert as next": "", "Insert file as next": "", "Insert playlist": "", + "Insert tracks from playlist": "", "Insert tracks from playlist…": "", + "Insert voice track": "", "Insert voice track…": "", "Inserts": "", "Install now": "", @@ -300,6 +324,7 @@ "Intro sound": "", "It goes into the playlist where that time falls, going by the start times.": "", "Item": "", + "Its files are where they were on the computer it was saved on. Tell RadioMaster where those folders are on the server; paths under them are changed to match.": "", "JINGLE": "", "Jingles": "", "Jingles & adverts (their rule's files)": "", @@ -325,6 +350,7 @@ "Level the track to a common loudness": "", "Library": "", "Library cleanup": "", + "Library cleanup works on the files of the computer it runs on. While you're connected, the library is the server's, so disconnect to tidy this computer's library.": "", "Library folders": "", "List publicly": "", "Listen to MIDI controllers": "", @@ -334,6 +360,7 @@ "Listening to:": "", "Live station status; historical audience graphs require a stats source.": "", "Load list": "", + "Load onto the server": "", "Load playlist (M3U) and play": "", "Loading the server's settings…": "", "Local session": "", @@ -414,6 +441,7 @@ "Not found anywhere": "", "Notch": "", "Notepad": "", + "Nothing here.": "", "Nothing in the play history for this period.": "", "Nothing logged yet.": "", "Nothing scheduled.": "", @@ -430,9 +458,12 @@ "Only": "", "Only administrators can manage user accounts.": "", "Only allow during scheduled times": "", + "Only the server's administrators can update, restart or stop it.": "", "Open AzuraCast tool": "", + "Open a folder, then choose it (or select one of its folders).": "", "Open in your browser": "", "Open page in your browser ↗": "", + "Open playlist": "", "Open playlist…": "", "Open the Removed tracks folder": "", "Opus in Ogg (ffmpeg)": "", @@ -504,6 +535,8 @@ "R": "", "Radio": "", "Radio automation, playback and studio settings.": "", + "RadioMaster Server is behind — click to update it": "", + "RadioMaster Server is behind: ask one of its administrators to update it": "", "RadioMaster is built on these open-source projects:": "", "RadioMaster — radio automation for Icecast, Shoutcast and AzuraCast, written in Rust.": "", "Ratio": "", @@ -513,6 +546,7 @@ "Recent": "", "Recordings": "", "Recordings are saved as WAV in the voicetracks folder of your RadioMaster data folder.": "", + "Recordings folder": "", "Release": "", "Release page": "", "Reload": "", @@ -547,6 +581,7 @@ "Reset panel layout": "", "Reset smart settings": "", "Reset!": "", + "Restart": "", "Reverse order": "", "Rewrite the tags of these tracks?": "", "Run a station command": "", @@ -569,6 +604,7 @@ "Save playlist as…": "", "Save preset": "", "Save snapshot": "", + "Save the voice track on the server in": "", "Save this report as a PDF": "", "Save your playlists and settings as a station profile? They come back exactly as they are when you disconnect.": "", "Saved": "", @@ -590,8 +626,12 @@ "Send track titles": "", "Server": "", "Server info…": "", + "Server program": "", "Server settings…": "", "Server type": "", + "Server users": "", + "Server users…": "", + "Server:": "", "Set": "", "Set to the playhead / cursor position": "", "Set volume (0-1)": "", @@ -605,6 +645,8 @@ "Show": "", "Show a line where each new hour starts": "", "Show cart wall": "", + "Show every file in the folder, not only audio": "", + "Show every file, not only audio": "", "Show every track in the library or playlist next to the editor": "", "Shuffle upcoming": "", "Sign in": "", @@ -673,13 +715,19 @@ "The program gets 16-bit 44.1 kHz stereo PCM on its input and must write the stream to its output. {bitrate} = the bitrate in kbps.": "", "The queue is empty.": "", "The server has no sound cards.": "", + "The server keeps its own sound cards, remote control and plugin settings. Paths left unmapped stay as they are.": "", "The server was started with --no-audio, so it streams only until it is started without it.": "", + "The station goes quiet while the server restarts (a few seconds) — best done between shows. This app reconnects by itself.": "", "The template's times of day and timed inserts follow the clock from where the new tracks start.": "", + "There's no RadioMaster Server download for the server's system: update it by hand": "", + "These accounts sign in to the server (from the app and the web portal). The server checks every action against them.": "", "These belong to the server itself. Station settings (playlists, streams, adverts…) are in the usual places while you're connected.": "", "Things that happen at a set time, or again and again: a time announcement or any other playlist item, a new playlist from a template, adverts, a playlist going on air, encoders, volume…": "", "This copy wasn't installed with the setup program, so download the new zip (or the installer) from the page.": "", + "This folder is empty.": "", "This is what the stream hears. Outputs marked Studio monitor are always muted while the mic is on.": "", "This page needs an AzuraCast API key.": "", + "This profile has no file paths.": "", "This report": "", "Threshold": "", "Tick every device that should play the on-air mix. The first working device is the primary; the others get a copy (monitor / headphones / second sound card). Each has its own volume.": "", @@ -696,6 +744,8 @@ "Track List Repeat Protection…": "", "Track Title": "", "Track editor": "", + "Track list folder": "", + "Track list playlist": "", "Tracks whose artist, title, genre or file name has one of these words are left out": "", "Transitions": "", "Translate RadioMaster: export the template, fill in the right-hand side of each line, save it as e.g. Deutsch.json in the lang folder, then pick it here. Share it on GitHub so everyone gets it.": "", @@ -708,11 +758,14 @@ "Twelve sliders at fixed frequencies": "", "Type": "", "Type one above (https://…) and press Enter.": "", - "Type the folder as the server sees it, e.g. /srv/music or D:\\Music.": "", "Up one folder": "", "Up to 16 bands; each picks its filter, frequency, gain and width": "", "Upcoming tracks": "", + "Update, restart or stop…": "", "Upload folder": "", + "Upload from this computer…": "", + "Upload the dropped files to": "", + "Uploading…": "", "Use": "", "Use HTTPS": "", "Use Let's Encrypt's staging server (for trying it out)": "", @@ -741,6 +794,8 @@ "Weekends": "", "What plays": "", "What plays, how often, and when. Adverts and jingles never count as songs.": "", + "What this account may do is set by the server's administrators": "", + "What's new (release page)": "", "When a Track List row picks a track, skip:": "", "When it's in force": "", "Where ▶ Play / Preview mix in the track editor is heard. It is never sent to the stream, but if it is the same device as your on-air output, it will be heard in the room.": "", @@ -761,6 +816,7 @@ "XML file": "", "Yes": "", "You can't disable your own account": "", + "You can't take away your own administrator access": "", "Your account can't change playlists": "", "Your account isn't allowed to do this": "", "Your account isn't allowed to edit playlists": "", @@ -771,6 +827,7 @@ "Zoom": "", "Zoom on the hand-over to the next track": "", "a folder or chosen files": "", + "administrator": "", "advert": "", "adverts": "", "after every": "", @@ -826,6 +883,7 @@ "keeping": "", "keeps playing at full level": "", "last:": "", + "leave empty to keep": "", "leave out": "", "length": "", "library": "", @@ -848,6 +906,7 @@ "off": "", "on air": "", "on the clock": "", + "on the server": "", "one file at random": "", "optional": "", "optional folder": "", @@ -956,9 +1015,11 @@ "✔ Do it": "", "✔ Put it in the playlist": "", "✔ RadioMaster is up to date.": "", + "✔ The server is up to date.": "", "✖ Close": "", "✖ not found — needed for AAC, Opus, Vorbis and FLAC streams": "", "✨ Smart cues": "", + "➕ Add a folder": "", "➕ Add advert": "", "➕ Add band": "", "➕ Add category": "", @@ -990,6 +1051,7 @@ "⬇ Export": "", "⬇ Move down": "", "⬇ Open the download page": "", + "⬇ Update the server": "", "🌐 New web panel": "", "🎤 Artists": "", "🎤 Streamers/DJs": "", diff --git a/server/radiomaster-server.service b/server/radiomaster-server.service index ec69be8..990b98d 100644 --- a/server/radiomaster-server.service +++ b/server/radiomaster-server.service @@ -1,7 +1,8 @@ # systemd unit for RadioMaster Server. # sudo useradd --system --create-home --home-dir /var/lib/radiomaster radiomaster -# sudo cp RadioMaster-Server /usr/local/bin/ -# sudo -u radiomaster RadioMaster-Server --data /var/lib/radiomaster --setup +# sudo install -d -o radiomaster -g radiomaster /opt/radiomaster +# sudo install -o radiomaster -g radiomaster -m 755 RadioMaster-Server /opt/radiomaster/ +# sudo -u radiomaster /opt/radiomaster/RadioMaster-Server --data /var/lib/radiomaster --setup # sudo cp radiomaster-server.service /etc/systemd/system/ # sudo systemctl enable --now radiomaster-server # journalctl -u radiomaster-server -f (the station log) @@ -14,7 +15,9 @@ Wants=network-online.target User=radiomaster # add the account to the "audio" group to play through a sound card SupplementaryGroups=audio -ExecStart=/usr/local/bin/RadioMaster-Server --data /var/lib/radiomaster +# (in a folder the service account owns, so administrators can update it from the app) +ExecStart=/opt/radiomaster/RadioMaster-Server --data /var/lib/radiomaster +# also how it restarts after an update or Restart in the app (it ends with a failure code for that) Restart=on-failure RestartSec=5 KillSignal=SIGTERM diff --git a/src/adsui.rs b/src/adsui.rs index 27059dd..683913a 100644 --- a/src/adsui.rs +++ b/src/adsui.rs @@ -242,7 +242,7 @@ impl App { ui.horizontal(|ui| { ui.add(egui::TextEdit::singleline(&mut r.folder).desired_width(280.0).hint_text(tr("folder"))); if ui.button("…").on_hover_text(tr("Choose a folder")).clicked() { - self.dlg(move || rfd::FileDialog::new().pick_folder().map(|p| Dlg::RotFolder(i, p))); + self.pick(Pick::Folder, tr("Choose a folder"), move |_, v| Some(Dlg::RotFolder(i, v.into_iter().next()?))); } }); } @@ -269,9 +269,7 @@ impl App { } ui.horizontal(|ui| { if ui.button(if r.files.is_empty() { tr("…or choose files") } else { tr("➕ Add files") }).clicked() { - self.dlg(move || { - rfd::FileDialog::new().add_filter("Audio", &crate::library::EXTS).pick_files().map(|v| Dlg::RotFiles(i, v)) - }); + self.pick(Pick::Files(AUDIO.0, AUDIO.1), tr("Choose files"), move |_, v| Some(Dlg::RotFiles(i, v))); } if !r.files.is_empty() { ui.label(RichText::new(tr("(chosen files are used instead of the folder)")).small().color(GRAY)); @@ -431,8 +429,8 @@ impl App { }); ui.add(egui::TextEdit::singleline(&mut a.path).desired_width(260.0).hint_text(tr("audio file"))); if ui.button("…").on_hover_text(tr("Pick the audio file")).clicked() { - self.dlg(move || { - rfd::FileDialog::new().add_filter("Audio", &crate::library::EXTS).pick_file().map(|p| Dlg::AdPath(i, p)) + self.pick(Pick::File(AUDIO.0, AUDIO.1), tr("Pick the audio file"), move |_, v| { + Some(Dlg::AdPath(i, v.into_iter().next()?)) }); } if delete_button(ui, ("advert", i), tr("this advert")) { diff --git a/src/app.rs b/src/app.rs index 6500518..a496aeb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,7 +4,7 @@ use crate::config::{data_dir, CartPad, ServerKind}; use crate::i18n::tr; use crate::items::PlEntry; use crate::library::Track; -use crate::playout::{parse_m3u, M3uRow, PlayState, Playout}; +use crate::playout::{M3uRow, PlayState, Playout}; use crate::scheduler::{ActionKind, Event}; use crate::stream::EncState; use crate::workzones::{Tab, WorkzoneSet}; @@ -101,31 +101,72 @@ enum Dlg { /// files (folders already listed) for playlist tab, at row (None: after the selection when /// that tab is viewed, otherwise the end) Files(usize, Option, Vec), - /// file explorer listing: (panel key, folder, entries) - FolderList(String, PathBuf, Vec<(bool, PathBuf)>), + /// file explorer listing: (panel key, folder asked for, listing) + FolderList(String, PathBuf, crate::playout::sync::FsList), /// sound card outputs, inputs, and the default output Devices(Vec, Vec, Option), /// the play history for Reports History(Vec), + /// a message for the status bar and log + Say(String), + /// a profile file was copied into the profiles folder + ProfileImported(String), + /// a server folder for a profile mapping row + MapTo(usize, PathBuf), } /// Dialog results that read the disk do it here, on the dialog's thread, not on the UI thread. +/// `fs` is where the picked file is: this computer or the connected server. impl Dlg { - fn add_folder(p: PathBuf) -> Dlg { - Dlg::AddFolder(crate::library::audio_files_in(&p)) + fn add_folder(fs: &crate::link::Fs, p: PathBuf) -> Dlg { + Dlg::AddFolder(fs.audio_files_in(&p)) } - fn load_m3u(p: PathBuf) -> Dlg { - Dlg::LoadM3u(parse_m3u(&p)) + fn load_m3u(fs: &crate::link::Fs, p: PathBuf) -> Dlg { + Dlg::LoadM3u(fs.m3u(&p)) } - fn insert_playlist(p: PathBuf) -> Dlg { - Dlg::InsertPlaylist(parse_m3u(&p)) + fn insert_playlist(fs: &crate::link::Fs, p: PathBuf) -> Dlg { + Dlg::InsertPlaylist(fs.m3u(&p)) } - fn insert_from_playlist(p: PathBuf) -> Dlg { - let rows = parse_m3u(&p); + fn insert_from_playlist(fs: &crate::link::Fs, p: PathBuf) -> Dlg { + let rows = fs.m3u(&p); Dlg::InsertFromPlaylist(p, rows) } } +/// A station profile on its way to the server, with its folders mapped to the server's. +struct ProfileMap { + name: String, + value: serde_json::Value, + /// (folder in the profile, folder on the server) + maps: Vec<(String, String)>, + has_playlists: bool, + playlists: bool, +} + +impl ProfileMap { + fn new(name: String, value: serde_json::Value) -> Self { + let maps = crate::playout::sync::path_roots(&value).into_iter().map(|r| (r, String::new())).collect(); + let has_playlists = value.get("_session").is_some(); + ProfileMap { name, value, maps, has_playlists, playlists: false } + } +} + +/// An account as the users grid shows it. +pub(super) struct UserRow { + pub name: String, + pub admin: bool, + pub perms: crate::auth::Perms, + pub disabled: bool, +} + +/// What was done in the users grid. +pub(super) enum UserAct { + Add { name: String, password: String, admin: bool, perms: crate::auth::Perms }, + Change { name: String, admin: bool, perms: crate::auth::Perms, disabled: bool }, + Password { name: String, password: String }, + Delete { name: String }, +} + enum PlAct { Play(usize), Remove, @@ -151,8 +192,11 @@ mod cleanupui; mod controlsui; #[path = "equi.rs"] mod equi; +#[path = "fileui.rs"] +mod fileui; #[path = "genui.rs"] mod genui; +use fileui::{Pick, AUDIO, M3U}; #[path = "panes.rs"] mod panes; #[path = "pltools.rs"] @@ -161,6 +205,8 @@ mod pltools; mod plview; #[path = "reportui.rs"] mod reportui; +#[path = "serveradmin.rs"] +mod serveradmin; #[path = "serverui.rs"] mod serverui; @@ -248,7 +294,9 @@ pub struct App { browsers: Browsers, web_state: HashMap, fx_dir: HashMap, - fx_list: HashMap>)>, + fx_list: HashMap)>, + /// explorers that show every file, not only audio (by panel key) + fx_all: std::collections::HashSet, /// explorer folders being listed in the background (panel key → folder) fx_loading: HashMap, aux_spawn: HashMap)>, @@ -302,6 +350,8 @@ pub struct App { profile_name: String, profile_list: Vec, show_profiles: bool, + /// a profile being loaded onto the server + profile_map: Option, accounts: crate::auth::Accounts, logged_in: Option, login_name: String, @@ -323,6 +373,7 @@ pub struct App { art_rx: Option)>>, /// File → Connect to Server srv: serverui::ServerUi, + files: fileui::FileUi, } /// The one delete button used everywhere: 🗑, then "Delete!" / "Keep" so nothing goes by accident. @@ -476,13 +527,12 @@ pub(super) fn action_edit( browse } -/// File dialog for an action that takes a file: a playlist for "Load playlist", audio otherwise. -pub(super) fn action_file_dialog(action: ActionKind) -> rfd::FileDialog { - let d = rfd::FileDialog::new(); +/// What an action that takes a file picks: a playlist for "Load playlist", audio otherwise. +fn action_pick(action: ActionKind) -> Pick { if action == ActionKind::LoadPlaylistAndPlay { - d.add_filter("Playlist", &["m3u", "m3u8"]) + Pick::File(M3U.0, M3U.1) } else { - d.add_filter("Audio", &crate::library::EXTS) + Pick::File(AUDIO.0, AUDIO.1) } } @@ -595,6 +645,7 @@ impl App { right_w: 400.0, art_rx: None, srv: Default::default(), + files: Default::default(), seek_drag: None, mic_stream: None, mic_on: false, @@ -612,6 +663,7 @@ impl App { fx_dir: HashMap::new(), fx_list: HashMap::new(), fx_loading: HashMap::new(), + fx_all: Default::default(), aux_spawn: HashMap::new(), layout_checked: std::time::Instant::now(), perf: None, @@ -664,6 +716,7 @@ impl App { .filter_map(|e| e.path().file_stem().map(|s| s.to_string_lossy().to_string())) .collect(), show_profiles: false, + profile_map: None, accounts: crate::auth::Accounts::load(), logged_in: None, login_name: String::new(), @@ -704,7 +757,8 @@ impl App { /// Add files and folders to playlist `tab` (see `Dlg::Files`) once the folders have been listed /// off the UI thread. pub(super) fn add_files_later(&self, tab: usize, at: Option, paths: Vec) { - self.dlg(move || Some(Dlg::Files(tab, at, crate::library::expand_paths(paths)))); + let fs = self.fs(); + self.dlg(move || Some(Dlg::Files(tab, at, fs.expand(paths)))); } fn handle_dialogs(&mut self, core: &mut Playout) { @@ -786,8 +840,13 @@ impl App { } self.insert_rows_at(core, tab, at, rows); } - Dlg::FolderList(key, dir, list) => { + Dlg::FolderList(key, mut dir, list) => { self.fx_loading.remove(&key); + // the server's starting folder: shown as itself from now on + if dir.as_os_str().is_empty() && self.fx_dir.get(&key).is_some_and(|d| d.as_os_str().is_empty()) { + dir = list.path.clone(); + self.fx_dir.insert(key.clone(), dir.clone()); + } self.fx_list.insert(key, (dir, std::time::Instant::now(), Arc::new(list))); } Dlg::Devices(outs, ins, default) => { @@ -807,6 +866,18 @@ impl App { } } Dlg::History(all) => self.rep.set_history(all), + Dlg::Say(m) => core.say(m), + Dlg::ProfileImported(name) => { + core.say(format!("Imported the profile {name}")); + if !self.profile_list.contains(&name) { + self.profile_list.push(name); + } + } + Dlg::MapTo(i, p) => { + if let Some(row) = self.profile_map.as_mut().and_then(|m| m.maps.get_mut(i)) { + row.1 = p.to_string_lossy().to_string(); + } + } Dlg::SaveM3u(p) => core.with_tab(self.view_pl, |c| c.save_m3u(&p)), Dlg::RenderWav(p) => core.with_tab(self.view_pl, |c| c.render_playlist(p)), Dlg::ExportCsv(p) => match core.with_tab(self.view_pl, |c| c.export_csv(&p)) { @@ -834,7 +905,10 @@ impl App { } Dlg::CleanupFolder(p) => self.clean.extra = Some(p), Dlg::LibFolder(p) => { - core.cfg.library_folders.push(p.to_string_lossy().to_string()); + let f = p.to_string_lossy().to_string(); + if !core.cfg.library_folders.contains(&f) { + core.cfg.library_folders.push(f); + } core.start_scan(); } Dlg::Cart(i, p) => { @@ -912,7 +986,7 @@ impl App { ui.close_menu(); } if ui.button(tr("Open playlist…")).clicked() { - self.dlg(|| rfd::FileDialog::new().add_filter("Playlist", &["m3u", "m3u8"]).pick_file().map(Dlg::load_m3u)); + self.pick(Pick::File(M3U.0, M3U.1), tr("Open playlist"), |fs, v| Some(Dlg::load_m3u(fs, v.into_iter().next()?))); ui.close_menu(); } if ui.button(tr("Save playlist as…")).clicked() { @@ -1027,6 +1101,7 @@ impl App { ToolWin::AzuraCast, ToolWin::Reports, ToolWin::History, + ToolWin::FileExplorer, ToolWin::Cleanup, ToolWin::Notepad, ToolWin::Plugins, @@ -1174,6 +1249,7 @@ impl App { ui.add(egui::Image::new((t.id(), egui::vec2(18.0, 18.0)))); } self.server_chip(ui); + self.server_update_button(ui); if let Some(v) = &new_version { let b = egui::Button::new(RichText::new(format!("⬆ Update available: v{v}")).strong().color(Color32::WHITE)) .fill(Color32::from_rgb(20, 150, 235)); @@ -1571,7 +1647,7 @@ impl App { ui.horizontal(|ui| { ui.strong(tr("PLAYBACK QUEUE")); if ui.button(tr("Add files…")).clicked() { - self.dlg(|| rfd::FileDialog::new().add_filter("Audio", &crate::library::EXTS).pick_files().map(Dlg::AddFiles)); + self.pick(Pick::Files(AUDIO.0, AUDIO.1), tr("Add files"), |_, v| Some(Dlg::AddFiles(v))); } }); let start = core.current.map(|i| i + 1).unwrap_or(0); @@ -1988,101 +2064,17 @@ impl App { if !self.show_users || !self.logged_in.as_ref().map(|u| u.admin).unwrap_or(false) { return; } - use crate::auth::Perms; let mut open = self.show_users; let me = self.logged_in.as_ref().map(|u| u.name.clone()).unwrap_or_default(); - let mut changes: Vec<(String, bool, Perms, bool)> = vec![]; - let mut reset: Option<(String, String)> = None; - let mut delete: Option = None; + let rows: Vec = self + .accounts + .users + .iter() + .map(|u| UserRow { name: u.name.clone(), admin: u.admin, perms: u.perms, disabled: u.disabled }) + .collect(); + let mut acts = vec![]; Self::window_for(ctx, "Users and permissions", [1000.0, 520.0]).id(egui::Id::new("users_window")).open(&mut open).show(ctx, |ui| { - ui.label( - RichText::new(tr( - "Administrators can do everything, including managing users. Other operators only get the ticked permissions.", - )) - .small() - .color(GRAY), - ); - ui.add_space(4.0); - egui::ScrollArea::both().max_height(300.0).show(ui, |ui| { - egui::Grid::new("users_grid").striped(true).num_columns(11).spacing([10.0, 6.0]).show(ui, |ui| { - ui.strong(tr("User")); - ui.strong(tr("Admin")); - for (name, help) in Perms::LIST { - ui.strong(tr(name)).on_hover_text(tr(help)); - } - ui.strong(tr("Tools")).on_hover_text(tr("Which tools this user can open")); - ui.strong(tr("Disabled")); - ui.strong(tr("New password")); - ui.label(""); - ui.end_row(); - for u in &self.accounts.users { - let (mut admin, mut perms, mut disabled) = (u.admin, u.perms, u.disabled); - ui.label(if u.name == me { RichText::new(format!("{} (you)", u.name)).strong() } else { RichText::new(&u.name) }); - ui.checkbox(&mut admin, ""); - for k in 0..Perms::LIST.len() { - ui.add_enabled(!admin, egui::Checkbox::without_text(perms.get_mut(k))); - } - ui.add_enabled_ui(!admin, |ui| Self::tools_picker(ui, &u.name, &mut perms)); - ui.add_enabled(u.name != me, egui::Checkbox::without_text(&mut disabled)) - .on_disabled_hover_text(tr("You can't disable your own account")); - let pw = self.user_pw.entry(u.name.clone()).or_default(); - ui.horizontal(|ui| { - ui.add(egui::TextEdit::singleline(pw).password(true).hint_text(tr("12+ characters")).desired_width(120.0)); - if ui.add_enabled(pw.len() >= 12, egui::Button::new(tr("Set"))).clicked() { - reset = Some((u.name.clone(), pw.clone())); - } - }); - if self.user_delete.as_deref() == Some(&u.name) { - ui.horizontal(|ui| { - if ui.button(RichText::new(tr("Delete!")).color(RED)).clicked() { - delete = Some(u.name.clone()); - } - if ui.button(tr("Keep")).clicked() { - self.user_delete = None; - } - }); - } else if ui.add_enabled(u.name != me, egui::Button::new("🗑")).on_hover_text(tr("Delete this user")).clicked() { - self.user_delete = Some(u.name.clone()); - } - ui.end_row(); - if (admin, perms, disabled) != (u.admin, u.perms, u.disabled) { - changes.push((u.name.clone(), admin, perms, disabled)); - } - } - }); - }); - ui.separator(); - ui.strong(tr("Add a user")); - ui.horizontal_wrapped(|ui| { - ui.add(egui::TextEdit::singleline(&mut self.login_name).hint_text(tr("username")).desired_width(150.0)); - ui.add( - egui::TextEdit::singleline(&mut self.login_password) - .password(true) - .hint_text(tr("password (12+ characters)")) - .desired_width(190.0), - ); - ui.checkbox(&mut self.new_user_admin, tr("Administrator")); - }); - if !self.new_user_admin { - ui.horizontal_wrapped(|ui| { - for (k, (name, help)) in Perms::LIST.iter().enumerate() { - ui.checkbox(self.new_user_perms.get_mut(k), tr(name)).on_hover_text(tr(help)); - } - ui.label(tr("Tools:")); - Self::tools_picker(ui, "\u{0}new", &mut self.new_user_perms); - }); - } - if ui.button(RichText::new(tr("Create user")).strong()).clicked() { - let perms = if self.new_user_admin { Perms::default() } else { self.new_user_perms }; - match self.accounts.add_with(&self.login_name, &self.login_password, self.new_user_admin, perms) { - Ok(()) => { - self.auth_error = format!("Created {}", self.login_name.trim()); - self.login_name.clear(); - } - Err(e) => self.auth_error = e, - } - self.login_password.clear(); - } + acts = self.users_editor(ui, "users_grid", &rows, &me); if !self.auth_error.is_empty() { ui.colored_label( if self.auth_error.starts_with("Created") || self.auth_error.starts_with("Saved") { GREEN } else { RED }, @@ -2090,25 +2082,20 @@ impl App { ); } }); - for (name, admin, perms, disabled) in changes { - self.auth_error = match self.accounts.update(&name, admin, perms, disabled) { - Ok(()) => format!("Saved {name}"), - Err(e) => e, - }; - } - if let Some((name, pw)) = reset { - self.auth_error = match self.accounts.set_password(&name, &pw) { - Ok(()) => format!("Saved a new password for {name}"), - Err(e) => e, - }; - self.user_pw.remove(&name); - } - if let Some(name) = delete { - self.auth_error = match self.accounts.delete(&name) { - Ok(()) => format!("Saved: {name} deleted"), - Err(e) => e, - }; - self.user_delete = None; + for a in acts { + self.auth_error = match a { + UserAct::Add { name, password, admin, perms } => { + self.accounts.add_with(&name, &password, admin, perms).map(|_| format!("Created {}", name.trim())) + } + UserAct::Change { name, admin, perms, disabled } => { + self.accounts.update(&name, admin, perms, disabled).map(|_| format!("Saved {name}")) + } + UserAct::Password { name, password } => { + self.accounts.set_password(&name, &password).map(|_| format!("Saved a new password for {name}")) + } + UserAct::Delete { name } => self.accounts.delete(&name).map(|_| format!("Saved: {name} deleted")), + } + .unwrap_or_else(|e| e); } // keep the signed-in snapshot in step with edits to your own account if let Some(u) = self.logged_in.as_ref().and_then(|me| self.accounts.users.iter().find(|x| x.name == me.name)).cloned() { @@ -2117,13 +2104,120 @@ impl App { self.show_users = open; } + /// The accounts grid and the "Add a user" form, for this computer's accounts and a server's. + /// `me` is the account signed in (it can't be disabled or deleted from here). + pub(super) fn users_editor(&mut self, ui: &mut egui::Ui, grid: &str, users: &[UserRow], me: &str) -> Vec { + use crate::auth::Perms; + let mut acts = vec![]; + ui.label( + RichText::new(tr( + "Administrators can do everything, including managing users. Other operators only get the ticked permissions.", + )) + .small() + .color(GRAY), + ); + ui.add_space(4.0); + egui::ScrollArea::both().id_salt(grid).max_height(300.0).show(ui, |ui| { + egui::Grid::new(grid).striped(true).num_columns(11).spacing([10.0, 6.0]).show(ui, |ui| { + ui.strong(tr("User")); + ui.strong(tr("Admin")); + for (name, help) in Perms::LIST { + ui.strong(tr(name)).on_hover_text(tr(help)); + } + ui.strong(tr("Tools")).on_hover_text(tr("Which tools this user can open")); + ui.strong(tr("Disabled")); + ui.strong(tr("New password")); + ui.label(""); + ui.end_row(); + for u in users { + let key = format!("{grid}\u{0}{}", u.name); + let (mut admin, mut perms, mut disabled) = (u.admin, u.perms, u.disabled); + ui.label(if u.name == me { RichText::new(format!("{} (you)", u.name)).strong() } else { RichText::new(&u.name) }); + ui.add_enabled(u.name != me, egui::Checkbox::without_text(&mut admin)) + .on_disabled_hover_text(tr("You can't take away your own administrator access")); + for k in 0..Perms::LIST.len() { + ui.add_enabled(!admin, egui::Checkbox::without_text(perms.get_mut(k))); + } + ui.add_enabled_ui(!admin, |ui| Self::tools_picker(ui, &key, &mut perms)); + ui.add_enabled(u.name != me, egui::Checkbox::without_text(&mut disabled)) + .on_disabled_hover_text(tr("You can't disable your own account")); + let pw = self.user_pw.entry(key.clone()).or_default(); + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(pw).password(true).hint_text(tr("12+ characters")).desired_width(120.0)); + if ui.add_enabled(pw.len() >= 12, egui::Button::new(tr("Set"))).clicked() { + acts.push(UserAct::Password { name: u.name.clone(), password: std::mem::take(pw) }); + } + }); + if self.user_delete.as_deref() == Some(&key) { + ui.horizontal(|ui| { + if ui.button(RichText::new(tr("Delete!")).color(RED)).clicked() { + acts.push(UserAct::Delete { name: u.name.clone() }); + self.user_delete = None; + } + if ui.button(tr("Keep")).clicked() { + self.user_delete = None; + } + }); + } else if ui.add_enabled(u.name != me, egui::Button::new("🗑")).on_hover_text(tr("Delete this user")).clicked() { + self.user_delete = Some(key.clone()); + } + ui.end_row(); + if (admin, perms, disabled) != (u.admin, u.perms, u.disabled) { + acts.push(UserAct::Change { name: u.name.clone(), admin, perms, disabled }); + } + } + }); + }); + ui.separator(); + ui.strong(tr("Add a user")); + ui.horizontal_wrapped(|ui| { + ui.add(egui::TextEdit::singleline(&mut self.login_name).hint_text(tr("username")).desired_width(150.0)); + ui.add( + egui::TextEdit::singleline(&mut self.login_password) + .password(true) + .hint_text(tr("password (12+ characters)")) + .desired_width(190.0), + ); + ui.checkbox(&mut self.new_user_admin, tr("Administrator")); + }); + if !self.new_user_admin { + ui.horizontal_wrapped(|ui| { + for (k, (name, help)) in Perms::LIST.iter().enumerate() { + ui.checkbox(self.new_user_perms.get_mut(k), tr(name)).on_hover_text(tr(help)); + } + ui.label(tr("Tools:")); + Self::tools_picker(ui, &format!("{grid}\u{0}new"), &mut self.new_user_perms); + }); + } + if ui.button(RichText::new(tr("Create user")).strong()).clicked() { + let perms = if self.new_user_admin { Perms::default() } else { self.new_user_perms }; + acts.push(UserAct::Add { + name: std::mem::take(&mut self.login_name), + password: std::mem::take(&mut self.login_password), + admin: self.new_user_admin, + perms, + }); + } + acts + } + fn profiles_window(&mut self, ctx: &egui::Context, core: &mut Playout) { + self.profile_map_window(ctx, core); if !self.show_profiles { return; } + let connected = self.srv.connected(); let mut open = self.show_profiles; + let mut import = false; Self::window_for(ctx, "Station profiles", [460.0, 380.0]).open(&mut open).show(ctx, |ui| { ui.label(tr("Save or load individual station settings (including encoder passwords). Keep the profile directory private.")); + if connected { + ui.label( + RichText::new(tr("Connected: Load puts a profile's settings (and, if you like, its playlists) on the server, with its folders mapped to the server's.")) + .small() + .color(AMBER), + ); + } ui.horizontal(|ui| { ui.label(tr("Profile:")); ui.add(egui::TextEdit::singleline(&mut self.profile_name).desired_width(220.0)); @@ -2138,23 +2232,52 @@ impl App { } } } + if ui.button(tr("Import…")).on_hover_text(tr("Add a profile file (.json) from this computer")).clicked() { + import = true; + } }); for name in self.profile_list.clone() { ui.horizontal(|ui| { ui.label(&name); if ui.button(format!("Load##{name}")).clicked() { let path = data_dir().join("profiles").join(format!("{name}.json")); - if let Ok(data) = std::fs::read_to_string(path) { - if let Ok(cfg) = serde_json::from_str::(&data) { - core.cfg = cfg; - core.save_all(); - core.say(format!("Profile {name} loaded. Restart to apply all engine/network changes.")); + let value = std::fs::read_to_string(path).ok().and_then(|d| serde_json::from_str::(&d).ok()); + match value { + None => core.say(format!("Profile {name} couldn't be read")), + Some(v) if connected => self.profile_map = Some(ProfileMap::new(name.clone(), v)), + Some(mut v) => { + if let Some(o) = v.as_object_mut() { + o.remove("_session"); + } + if let Ok(cfg) = serde_json::from_value::(v) { + core.cfg = cfg; + core.save_all(); + core.say(format!("Profile {name} loaded. Restart to apply all engine/network changes.")); + } } } } }); } }); + if import { + // a profile is a file on this computer, connected or not + self.dlg(|| { + let f = rfd::FileDialog::new().add_filter("Station profile", &["json"]).pick_file()?; + let name: String = + f.file_stem()?.to_string_lossy().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_').collect(); + let dir = data_dir().join("profiles"); + let ok = std::fs::read_to_string(&f).ok().and_then(|d| serde_json::from_str::(&d).ok()).is_some(); + Some(if !ok || name.is_empty() { + Dlg::Say(format!("{} isn't a RadioMaster station profile", f.display())) + } else { + match std::fs::create_dir_all(&dir).and_then(|_| std::fs::copy(&f, dir.join(format!("{name}.json")))) { + Ok(_) => Dlg::ProfileImported(name), + Err(e) => Dlg::Say(format!("Couldn't import {}: {e}", f.display())), + } + }) + }); + } self.show_profiles = open; } @@ -2175,6 +2298,101 @@ impl App { &self.lib_lower.1 } + /// "Load onto the server": a profile's folders mapped to the server's, then its settings (and + /// playlists) put on the server. + fn profile_map_window(&mut self, ctx: &egui::Context, core: &mut Playout) { + let Some(mut m) = self.profile_map.take() else { return }; + if !self.srv.connected() { + return; + } + let mut open = true; + let (mut load, mut cancel, mut browse) = (false, false, None); + Self::window_for(ctx, "Load a profile onto the server", [640.0, 460.0]).id(egui::Id::new("profile_map")).collapsible(false).open(&mut open).show( + ctx, + |ui| { + egui::ScrollArea::vertical().max_height(ui.available_height() - 40.0).show(ui, |ui| { + ui.label(RichText::new(format!("Profile \"{}\"", m.name)).strong()); + ui.label(tr("Its files are where they were on the computer it was saved on. Tell RadioMaster where those folders are on the server; paths under them are changed to match.")); + ui.add_space(4.0); + egui::Grid::new("profile_map_grid").num_columns(4).spacing([8.0, 4.0]).show(ui, |ui| { + ui.strong(tr("Folder in the profile")); + ui.strong(tr("Folder on the server")); + ui.label(""); + ui.label(""); + ui.end_row(); + let mut del = None; + for (i, (from, to)) in m.maps.iter_mut().enumerate() { + ui.add(egui::TextEdit::singleline(from).desired_width(230.0)); + ui.add(egui::TextEdit::singleline(to).desired_width(230.0).hint_text(tr("leave empty to keep"))); + if ui.button("…").on_hover_text(tr("Choose the folder on the server")).clicked() { + browse = Some(i); + } + if ui.small_button("✖").on_hover_text(tr("Remove")).clicked() { + del = Some(i); + } + ui.end_row(); + } + if let Some(i) = del { + m.maps.remove(i); + } + }); + if ui.button(tr("➕ Add a folder")).clicked() { + m.maps.push((String::new(), String::new())); + } + if m.maps.is_empty() { + ui.label(RichText::new(tr("This profile has no file paths.")).color(GRAY)); + } + ui.add_space(4.0); + if m.has_playlists { + ui.checkbox(&mut m.playlists, tr("Also load its playlists (they replace the server's)")); + } + ui.label( + RichText::new(tr("The server keeps its own sound cards, remote control and plugin settings. Paths left unmapped stay as they are.")) + .small() + .color(GRAY), + ); + }); + ui.separator(); + ui.horizontal(|ui| { + if ui.button(RichText::new(tr("Load onto the server")).strong()).clicked() { + load = true; + } + if ui.button(tr("Cancel")).clicked() { + cancel = true; + } + }); + }, + ); + if let Some(i) = browse { + self.pick(Pick::Folder, tr("Folder on the server"), move |_, v| Some(Dlg::MapTo(i, v.into_iter().next()?))); + } + if load { + let mut v = m.value.clone(); + let changed = crate::playout::sync::map_paths(&mut v, &m.maps); + let session = v.as_object_mut().and_then(|o| o.remove("_session")); + match serde_json::from_value::(v) { + Ok(cfg) => { + core.set_station_cfg(cfg); + let tabs = + session.filter(|_| m.playlists).and_then(|s| serde_json::from_value::(s).ok()); + let with = if let Some(t) = tabs { + core.set_tabs(t); + self.view_pl = usize::MAX; + " and playlists" + } else { + "" + }; + core.say(format!("Profile {}'s settings{with} sent to the server ({changed} file paths mapped)", m.name)); + } + Err(e) => core.say(format!("Profile {} couldn't be loaded: {e}", m.name)), + } + return; + } + if open && !cancel { + self.profile_map = Some(m); + } + } + /// Keep the filtered library list (library tab, track editor list) in step with the search text. pub(super) fn refresh_lib_filter(&mut self, core: &Playout) { let key = (self.lib_filter.to_lowercase(), core.library_ver); @@ -2398,9 +2616,7 @@ impl App { } b.context_menu(|ui| { if ui.button(tr("Assign audio file…")).clicked() { - self.dlg(move || { - rfd::FileDialog::new().add_filter("Audio", &crate::library::EXTS).pick_file().map(|p| Dlg::Cart(i, p)) - }); + self.pick(Pick::File(AUDIO.0, AUDIO.1), tr("Assign audio file"), move |_, v| Some(Dlg::Cart(i, v.into_iter().next()?))); ui.close_menu(); } ui.label(tr("Label:")); @@ -2527,7 +2743,7 @@ impl App { ui.label(RichText::new(tr("Do")).strong()); if action_edit(ui, ("sched", i), &mut e.action, &mut e.path, &mut e.arg, &opts) { let a = e.action; - self.dlg(move || action_file_dialog(a).pick_file().map(|p| Dlg::SchedPath(i, p))); + self.pick(action_pick(a), tr("Choose a file"), move |_, v| Some(Dlg::SchedPath(i, v.into_iter().next()?))); } if e.action == ActionKind::InsertItem { let shown = match &e.item { @@ -3399,6 +3615,7 @@ impl eframe::App for App { return; } let t0 = std::time::Instant::now(); + let server_perms = self.srv.session().map(|s| if s.admin { crate::auth::Perms::default() } else { s.perms }); self.perm = match &self.logged_in { Some(u) if u.admin => crate::auth::Perms::default(), Some(u) => u.perms, @@ -3414,6 +3631,10 @@ impl eframe::App for App { v: 1, }, }; + if let Some(sp) = server_perms { + // connected: what this computer's account and the server's account both allow + self.perm = self.perm.and(sp); + } let arc = self.core.clone(); let mut core = arc.lock(); let lock_ms = t0.elapsed().as_secs_f32() * 1000.0; @@ -3423,7 +3644,26 @@ impl eframe::App for App { self.handle_dialogs(&mut core); let dropped: Vec = ctx.input(|i| i.raw.dropped_files.iter().filter_map(|f| f.path.clone()).collect()); if !dropped.is_empty() && self.perm.edit_playlists { - self.add_files_later(self.view_pl, None, dropped); + if self.srv.connected() { + // files from this computer: copied to a folder on the server first, then added + let tab = self.view_pl; + self.pick(Pick::Folder, tr("Upload the dropped files to"), move |fs, v| { + let dir = v.into_iter().next()?; + let (mut up, mut failed) = (vec![], vec![]); + for f in crate::library::expand_paths(dropped) { + match fs.upload(&f, &dir) { + Ok(p) => up.push(p), + Err(e) => failed.push(e), + } + } + if up.is_empty() && !failed.is_empty() { + return Some(Dlg::Say(format!("Not uploaded: {}", failed.join("; ")))); + } + Some(Dlg::Files(tab, None, up)) + }); + } else { + self.add_files_later(self.view_pl, None, dropped); + } } self.midi_input(ctx, &mut core); if !self.show_settings { @@ -3487,10 +3727,12 @@ impl eframe::App for App { let force = self.show_settings || self.show_users || self.show_profiles + || self.profile_map.is_some() || self.show_about || self.show_update || self.tag_edit.is_some() - || self.srv.window_open(); + || self.srv.window_open() + || self.files.window_open(); self.guard_web_views(ctx, "", force); self.browsers.finish(); // save the layout when it changed, at most every 2 seconds (never while a window is being dragged) diff --git a/src/auth.rs b/src/auth.rs index c57f454..192db05 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -124,6 +124,20 @@ impl Perms { self.denied |= 1 << t; } } + /// What both allow. + pub fn and(self, o: Perms) -> Perms { + Perms { + playback: self.playback && o.playback, + jingles: self.jingles && o.jingles, + edit_playlists: self.edit_playlists && o.edit_playlists, + tools: self.tools && o.tools, + streaming: self.streaming && o.streaming, + azuracast: self.azuracast && o.azuracast, + settings: self.settings && o.settings, + denied: self.denied | o.denied, + v: self.v.max(o.v), + } + } pub fn tools_allowed(&self) -> usize { (0..TOOLS.len()).filter(|&t| self.tool(t)).count() } @@ -173,6 +187,30 @@ pub struct Operator { pub disabled: bool, } +// (never the password hash, should an account end up in a log) +impl std::fmt::Debug for Operator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Operator") + .field("name", &self.name) + .field("admin", &self.admin) + .field("perms", &self.perms) + .field("disabled", &self.disabled) + .finish_non_exhaustive() + } +} + +impl Operator { + /// What this account may do: everything for an administrator, else its permissions. + #[allow(dead_code)] // (RadioMaster Server's) + pub fn effective(&self) -> Perms { + if self.admin { + Perms::default() + } else { + self.perms + } + } +} + #[derive(Default, Serialize, Deserialize)] pub struct Accounts { pub users: Vec, @@ -256,6 +294,16 @@ impl Accounts { self.users.remove(i); self.save() } + /// An active (not disabled) account by name. + #[allow(dead_code)] // (RadioMaster Server's) + pub fn active(&self, name: &str) -> Option<&Operator> { + self.users.iter().find(|u| !u.disabled && u.name == name) + } + /// At least one administrator can sign in. + #[allow(dead_code)] // (RadioMaster Server's) + pub fn has_admin(&self) -> bool { + self.admins() > 0 + } pub fn login(&self, name: &str, password: &str) -> Option { self.users.iter().find(|u| !u.disabled && u.name == name && verify(&u.password_hash, password)).cloned() } diff --git a/src/cleanupui.rs b/src/cleanupui.rs index 606705f..fd64dc0 100644 --- a/src/cleanupui.rs +++ b/src/cleanupui.rs @@ -225,6 +225,11 @@ impl App { } pub(super) fn tab_cleanup(&mut self, ui: &mut egui::Ui, core: &mut Playout) { + if self.srv.connected() { + // it deletes, moves and retags files: those are on the server, out of this app's reach + ui.label(tr("Library cleanup works on the files of the computer it runs on. While you're connected, the library is the server's, so disconnect to tidy this computer's library.")); + return; + } ui.horizontal_wrapped(|ui| { for (v, l) in [ (CleanView::Duplicates, tr("👯 Duplicates")), diff --git a/src/controlsui.rs b/src/controlsui.rs index b29cb96..541a679 100644 --- a/src/controlsui.rs +++ b/src/controlsui.rs @@ -162,9 +162,9 @@ impl App { "stop_jingles" if p.playback && p.jingles => core.stop_carts(), // ---- playlist editing "add_files" if edit => { - self.dlg(|| rfd::FileDialog::new().add_filter("Audio", &crate::library::EXTS).pick_files().map(Dlg::AddFiles)); + self.pick(Pick::Files(AUDIO.0, AUDIO.1), tr("Add files"), |_, v| Some(Dlg::AddFiles(v))); } - "add_folder" if edit => self.dlg(|| rfd::FileDialog::new().pick_folder().map(Dlg::add_folder)), + "add_folder" if edit => self.pick(Pick::Folder, tr("Add folder"), |fs, v| Some(Dlg::add_folder(fs, v.into_iter().next()?))), "add_url" if edit => self.open_item_dlg(DlgKind::Url), "add_command" if edit => self.open_item_dlg(DlgKind::Command), "add_tts" if edit => self.open_item_dlg(DlgKind::Tts), @@ -172,12 +172,12 @@ impl App { "add_pause" if edit => self.open_item_dlg(DlgKind::Pause), "add_hour" if edit => self.open_item_dlg(DlgKind::HourMarker), "insert_voice" if edit => { - self.dlg(|| rfd::FileDialog::new().add_filter("Audio", &crate::library::EXTS).pick_files().map(Dlg::InsertVoice)); + self.pick(Pick::Files(AUDIO.0, AUDIO.1), tr("Insert voice track"), |_, v| Some(Dlg::InsertVoice(v))); } "record_voice" if edit && p.tool(crate::auth::tool::VOICE_TRACK) => self.show_vt = true, "new_playlist" if edit => self.tab_ops.push(super::plview::TabOp::New), "open_playlist" if edit => { - self.dlg(|| rfd::FileDialog::new().add_filter("Playlist", &["m3u", "m3u8"]).pick_file().map(Dlg::load_m3u)); + self.pick(Pick::File(M3U.0, M3U.1), tr("Open playlist"), |fs, v| Some(Dlg::load_m3u(fs, v.into_iter().next()?))); } "save_playlist" if edit => { let name = format!("{}.m3u", core.tabs.get(self.view_pl).map(|t| t.name.clone()).unwrap_or_else(|| "playlist".into())); diff --git a/src/fileui.rs b/src/fileui.rs new file mode 100644 index 0000000..fca364b --- /dev/null +++ b/src/fileui.rs @@ -0,0 +1,340 @@ +//! Picking files and folders. On this computer that is the system's own dialog; while connected to +//! RadioMaster Server it is a browser of the server's folders (the files the station plays are +//! there), with an Upload button for files from this computer. +use super::*; +use crate::link::Fs; +use crate::playout::sync::FsList; +use std::path::Path; + +/// What a dialog picks. Files are filtered by (filter name, extensions); no extensions: any file. +#[derive(Clone, Copy)] +pub(super) enum Pick { + Folder, + File(&'static str, &'static [&'static str]), + Files(&'static str, &'static [&'static str]), +} + +/// Audio files, for `Pick::File` / `Pick::Files`. +pub(super) const AUDIO: (&str, &[&str]) = ("Audio", &crate::library::EXTS); +/// Playlists. +pub(super) const M3U: (&str, &[&str]) = ("Playlist", &["m3u", "m3u8"]); + +/// What to do with the paths picked (on a background thread, so it may read the disk or the server). +type Then = Box) -> Option + Send>; + +/// The server's file browser, while it is open. +struct Picker { + what: Pick, + title: String, + then: Then, + /// the folder shown (empty until the server says where browsing starts) + dir: PathBuf, + typed: String, + list: Option>, + loading: Option>, + selected: Vec, + all: bool, + /// uploads from this computer under way: (done, total, errors) + uploading: Option)>>, + upload_msg: String, +} + +#[derive(Default)] +pub(super) struct FileUi { + picker: Option, + /// asked for by `pick` (which only borrows the app), shown from the next frame + asked: std::cell::RefCell>, + /// where the server's file browser was last (it opens there again) + last_dir: PathBuf, +} + +impl FileUi { + pub(super) fn window_open(&self) -> bool { + self.picker.is_some() || self.asked.borrow().is_some() + } + + /// Forget the last folder (another server, or back to this computer). + pub(super) fn reset(&mut self) { + self.picker = None; + self.asked.get_mut().take(); + self.last_dir = PathBuf::new(); + } +} + +impl Pick { + fn files(self) -> bool { + !matches!(self, Pick::Folder) + } + fn exts(self) -> &'static [&'static str] { + match self { + Pick::Folder => &[], + Pick::File(_, e) | Pick::Files(_, e) => e, + } + } + /// The file can be picked (its extension is one of the filter's). + fn fits(self, p: &Path) -> bool { + let exts = self.exts(); + exts.is_empty() || p.extension().and_then(|e| e.to_str()).is_some_and(|e| exts.iter().any(|x| x.eq_ignore_ascii_case(e))) + } +} + +impl App { + /// Where picked files live now: the server while connected, else this computer. + pub(super) fn fs(&self) -> Fs { + match self.srv.session() { + Some(s) => Fs::Server(s), + None => Fs::Local, + } + } + + /// Pick files or a folder, then `then` turns the paths into a dialog result. While connected the + /// server's folders are shown; otherwise the system's dialog. + pub(super) fn pick(&self, what: Pick, title: &str, then: impl FnOnce(&Fs, Vec) -> Option + Send + 'static) { + if self.srv.connected() { + let dir = self.files.last_dir.clone(); + *self.files.asked.borrow_mut() = Some(Picker { + what, + title: title.to_string(), + then: Box::new(then), + typed: dir.display().to_string(), + dir, + list: None, + loading: None, + selected: vec![], + all: false, + uploading: None, + upload_msg: String::new(), + }); + return; + } + self.dlg(move || { + let mut d = rfd::FileDialog::new().set_title(title_text(what)); + if let Pick::File(name, exts) | Pick::Files(name, exts) = what { + if !exts.is_empty() { + d = d.add_filter(name, exts); + } + } + let picked = match what { + Pick::Folder => d.pick_folder().map(|p| vec![p]), + Pick::File(..) => d.pick_file().map(|p| vec![p]), + Pick::Files(..) => d.pick_files(), + }?; + then(&Fs::Local, picked) + }); + } + + pub(super) fn server_picker_window(&mut self, ctx: &egui::Context) { + if let Some(p) = self.files.asked.get_mut().take() { + self.files.picker = Some(p); + } + let Some(mut p) = self.files.picker.take() else { return }; + let fs = self.fs(); + if !fs.is_server() { + return; // disconnected meanwhile + } + if let Some(rx) = &p.loading { + if let Ok(list) = rx.try_recv() { + p.loading = None; + p.dir = list.path.clone(); + p.typed = list.path.display().to_string(); + p.selected.retain(|s| list.entries.iter().any(|e| &e.path == s)); + p.list = Some(Arc::new(list)); + } + } + if let Some(rx) = &p.uploading { + if let Ok((done, total, errors)) = rx.try_recv() { + p.upload_msg = if errors.is_empty() { format!("Uploaded {done} of {total}") } else { errors.join("; ") }; + if done + errors.len() >= total { + p.uploading = None; + p.list = None; // list the folder again, with the new files + } + } + } + if p.list.is_none() && p.loading.is_none() { + let (tx, rx) = mpsc::channel(); + let (fs, dir, all, ctx, folders) = + (fs.clone(), p.dir.clone(), p.all, ctx.clone(), self.core.lock().cfg.library_folders.clone()); + thread::spawn(move || { + let _ = tx.send(fs.list(&dir, all, &folders)); + ctx.request_repaint(); + }); + p.loading = Some(rx); + } + let mut open = true; + let mut done: Option> = None; + let mut cancel = false; + let mut go: Option = None; + let mut upload = false; + let title = format!("{} — {}", p.title, tr("on the server")); + Self::window_for(ctx, &title, [640.0, 520.0]).id(egui::Id::new("server_picker")).collapsible(false).open(&mut open).show( + ctx, + |ui| { + let list = p.list.clone(); + ui.horizontal_wrapped(|ui| { + if ui.small_button("⬆").on_hover_text(tr("Up one folder")).clicked() { + go = list.as_ref().and_then(|l| l.parent.clone()); + } + for place in list.iter().flat_map(|l| l.places.iter()) { + let label = + place.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| place.display().to_string()); + if ui.small_button(label).on_hover_text(place.display().to_string()).clicked() { + go = Some(place.clone()); + } + } + }); + ui.horizontal(|ui| { + let r = ui.add(egui::TextEdit::singleline(&mut p.typed).desired_width(ui.available_width() - 150.0)); + if (r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) || ui.button(tr("Go")).clicked() { + go = Some(PathBuf::from(p.typed.trim())); + } + if ui.checkbox(&mut p.all, tr("All files")).on_hover_text(tr("Show every file, not only audio")).changed() { + p.list = None; + } + }); + ui.separator(); + let footer = 70.0; + egui::ScrollArea::vertical().max_height((ui.available_height() - footer).max(80.0)).auto_shrink([false, false]).show( + ui, + |ui| { + let Some(list) = &list else { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(tr("Asking the server…")); + }); + return; + }; + if !list.error.is_empty() { + ui.colored_label(RED, format!("{}: {}", list.path.display(), list.error)); + } + for e in list.entries.iter().take(5000) { + let name = e.path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let pickable = if e.dir { !p.what.files() } else { p.what.files() && p.what.fits(&e.path) }; + let sel = p.selected.contains(&e.path); + let text = if e.dir { + RichText::new(format!("📁 {name}")) + } else if pickable { + RichText::new(format!("🎵 {name}")) + } else { + RichText::new(format!(" {name}")).color(GRAY) + }; + let r = ui.selectable_label(sel, text); + if r.double_clicked() { + if e.dir { + go = Some(e.path.clone()); + } else if pickable { + done = Some(vec![e.path.clone()]); + } + } else if r.clicked() && pickable { + let multi = matches!(p.what, Pick::Files(..)) && ui.input(|i| i.modifiers.command || i.modifiers.shift); + if multi { + if sel { + p.selected.retain(|s| s != &e.path); + } else { + p.selected.push(e.path.clone()); + } + } else { + p.selected = vec![e.path.clone()]; + } + } + } + if list.entries.is_empty() && list.error.is_empty() { + ui.label(RichText::new(tr("Nothing here.")).color(GRAY)); + } + }, + ); + ui.separator(); + ui.label( + RichText::new(match p.what { + Pick::Folder => tr("Open a folder, then choose it (or select one of its folders)."), + Pick::File(..) => tr("Double-click a file, or select it and choose it."), + Pick::Files(..) => tr("Ctrl+click to select several files."), + }) + .small() + .color(GRAY), + ); + ui.horizontal(|ui| { + let label = match p.what { + Pick::Folder if p.selected.is_empty() => tr("Choose this folder"), + _ => tr("Choose"), + }; + let ready = p.list.as_ref().is_some_and(|l| l.error.is_empty()) && (!p.what.files() || !p.selected.is_empty()); + if ui.add_enabled(ready, egui::Button::new(RichText::new(label).strong())).clicked() { + done = Some(if p.selected.is_empty() { vec![p.dir.clone()] } else { p.selected.clone() }); + } + if ui + .add_enabled(p.uploading.is_none() && p.list.is_some(), egui::Button::new(tr("Upload from this computer…"))) + .on_hover_text(tr("Copy files from this computer into this folder on the server")) + .clicked() + { + upload = true; + } + if p.uploading.is_some() { + ui.spinner(); + } + if ui.button(tr("Cancel")).clicked() { + cancel = true; + } + }); + if !p.upload_msg.is_empty() { + ui.label(RichText::new(&p.upload_msg).small()); + } + }, + ); + if let Some(dir) = go { + p.dir = dir; + p.list = None; + p.loading = None; + p.selected.clear(); + } + if upload { + let (tx, rx) = mpsc::channel(); + let (fs, dir, ctx, what) = (fs.clone(), p.dir.clone(), ctx.clone(), p.what); + thread::spawn(move || { + let mut d = rfd::FileDialog::new(); + if let Pick::File(name, exts) | Pick::Files(name, exts) = what { + if !exts.is_empty() { + d = d.add_filter(name, exts); + } + } + let files = d.pick_files().unwrap_or_default(); + let mut errors = vec![]; + let _ = tx.send((0, files.len(), vec![])); + for (i, f) in files.iter().enumerate() { + if let Err(e) = fs.upload(f, &dir) { + errors.push(e); + } + let _ = tx.send((i + 1 - errors.len(), files.len(), errors.clone())); + ctx.request_repaint(); + } + ctx.request_repaint(); + }); + p.uploading = Some(rx); + p.upload_msg = tr("Uploading…").into(); + } + if !p.dir.as_os_str().is_empty() { + self.files.last_dir = p.dir.clone(); + } + if let Some(paths) = done { + let (tx, ctx, then) = (self.dlg_tx.clone(), ctx.clone(), p.then); + thread::spawn(move || { + if let Some(d) = then(&fs, paths) { + let _ = tx.send(d); + ctx.request_repaint(); + } + }); + return; + } + if open && !cancel { + self.files.picker = Some(p); + } + } +} + +fn title_text(what: Pick) -> String { + match what { + Pick::Folder => tr("Choose a folder"), + Pick::File(..) => tr("Choose a file"), + Pick::Files(..) => tr("Choose files"), + } + .into() +} diff --git a/src/genui.rs b/src/genui.rs index 2cea98d..6d3cbcf 100644 --- a/src/genui.rs +++ b/src/genui.rs @@ -108,7 +108,7 @@ impl App { ui.label(tr("folder")); ui.add(egui::TextEdit::singleline(&mut c.folder).desired_width(240.0).hint_text(tr("any"))); if ui.button("…").on_hover_text(tr("Browse")).clicked() { - self.dlg(move || rfd::FileDialog::new().pick_folder().map(|p| Dlg::CatFolder(i, p))); + self.pick(Pick::Folder, tr("Category folder"), move |_, v| Some(Dlg::CatFolder(i, v.into_iter().next()?))); } if delete_button(ui, ("category", i), tr("this category")) { del = Some(i); diff --git a/src/link.rs b/src/link.rs index 3c789af..12d00d1 100644 --- a/src/link.rs +++ b/src/link.rs @@ -28,6 +28,10 @@ pub struct Session { pub version: String, /// SHA-256 of the self-signed certificate the user trusted for this server (empty: none needed) pub pin: String, + /// the account is one of the server's administrators + pub admin: bool, + /// what the account may do on the server (it checks every call too) + pub perms: crate::auth::Perms, } /// The address was typed without http:// or https://. @@ -54,6 +58,8 @@ pub fn normalize(addr: &str) -> String { enum Fail { /// not signed in (any more) Auth(String), + /// the account may not do that + Denied(String), /// the server's copy changed first Conflict, Other(String), @@ -65,7 +71,7 @@ impl Fail { Fail::Other(e) if e == crate::tls::UNTRUSTED || e == crate::tls::CHANGED => { "the server's HTTPS certificate isn't the one trusted: disconnect and connect again to check it".into() } - Fail::Auth(e) | Fail::Other(e) => e.clone(), + Fail::Auth(e) | Fail::Denied(e) | Fail::Other(e) => e.clone(), Fail::Conflict => "the server's copy changed first".into(), } } @@ -103,6 +109,7 @@ fn call(agent: &ureq::Agent, base: &str, token: &str, path: .unwrap_or_else(|| format!("HTTP {code}")); Err(match code { 401 => Fail::Auth(msg), + 403 => Fail::Denied(msg), 409 => Fail::Conflict, _ => Fail::Other(msg), }) @@ -146,6 +153,11 @@ struct LoginReply { version: String, #[serde(default)] protocol: u32, + #[serde(default)] + admin: bool, + /// (servers before v0.2.9 had one account, which could do everything) + #[serde(default)] + perms: crate::auth::Perms, } /// Why signing in didn't work. @@ -216,6 +228,8 @@ fn login_at(base: &str, user: &str, password: &str, pin: &str) -> Result Result, String> { + call(&agent(&s.pin), &s.base, &s.token, "users", None).map_err(|e| e.text()) + } + + /// Change the server's accounts (administrators only; blocks). Returns them as they are now. + pub fn user_op(s: &Session, op: &sync::UserOp) -> Result, String> { + call(&agent(&s.pin), &s.base, &s.token, "users", Some(serde_json::to_value(op).unwrap_or_default())).map_err(|e| e.text()) + } + + /// Update, restart or stop the server: `what` is "update", "restart" or "stop" (administrators only; blocks). + pub fn server_control(s: &Session, what: &str) -> Result { + call(&agent(&s.pin), &s.base, &s.token, &format!("server/{what}"), Some(serde_json::json!({}))).map_err(|e| e.text()) + } + + /// The server's play history (history.csv) for Reports (blocks). + pub fn plays(s: &Session) -> Result { + #[derive(Deserialize)] + struct Plays { + csv: String, + } + call::(&agent(&s.pin), &s.base, &s.token, "plays", None).map(|p| p.csv).map_err(|e| e.text()) + } + /// End a sign-in on the server (in the background). pub fn sign_out(s: Session) { thread::spawn(move || { @@ -324,6 +362,89 @@ impl Link { } } +// ------------------------------------------------------------ files + +/// Where the files a dialog picks live: this computer, or the server the app is connected to +/// (its paths are the server's own). Every call reads the disk or the network: use it off the UI thread. +#[derive(Clone)] +pub enum Fs { + Local, + Server(Session), +} + +impl Fs { + pub fn is_server(&self) -> bool { + matches!(self, Fs::Server(_)) + } + + /// A folder's folders and audio files (`all`: every file). An empty path: where browsing starts. + pub fn list(&self, path: &std::path::Path, all: bool, library_folders: &[String]) -> sync::FsList { + match self { + Fs::Local => sync::list_folder(path.to_path_buf(), all, library_folders), + Fs::Server(s) => { + let body = serde_json::json!({ "path": path, "all": all }); + call(&agent(&s.pin), &s.base, &s.token, "fs/list", Some(body)).unwrap_or_else(|e| sync::FsList { + path: path.to_path_buf(), + error: e.text(), + ..Default::default() + }) + } + } + } + + /// Audio files as they are, and the audio files in folders (sorted, sub-folders included). + pub fn expand(&self, paths: Vec) -> Vec { + match self { + Fs::Local => crate::library::expand_paths(paths), + Fs::Server(s) => call(&agent(&s.pin), &s.base, &s.token, "fs/expand", Some(serde_json::to_value(paths).unwrap_or_default())) + .unwrap_or_default(), + } + } + + pub fn audio_files_in(&self, dir: &std::path::Path) -> Vec { + self.expand(vec![dir.to_path_buf()]) + } + + /// An M3U's rows (see `playout::parse_m3u`). + pub fn m3u(&self, path: &std::path::Path) -> Vec { + match self { + Fs::Local => crate::playout::parse_m3u(path), + Fs::Server(s) => { + call(&agent(&s.pin), &s.base, &s.token, "fs/m3u", Some(serde_json::json!({ "path": path }))).unwrap_or_default() + } + } + } + + /// Copy a file from this computer into `dir` on the server; returns where it went there. + pub fn upload(&self, file: &std::path::Path, dir: &std::path::Path) -> Result { + let Fs::Server(s) = self else { return Err("not connected to a server".into()) }; + let data = std::fs::File::open(file).map_err(|e| format!("{}: {e}", file.display()))?; + let len = data.metadata().map(|m| m.len()).unwrap_or(0); + let name = file.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let url = + format!("{}/api/v1/fs/upload?dir={}&name={}", s.base, urlencoding::encode(&dir.to_string_lossy()), urlencoding::encode(&name)); + // big files take a while on a slow line (and go as they are read, not held in memory) + let agent = ureq::AgentBuilder::new() + .timeout_connect(Duration::from_secs(5)) + .timeout(Duration::from_secs(600)) + .tls_config(crate::tls::client_config(&s.pin, Default::default())) + .build(); + #[derive(Deserialize)] + struct Put { + path: std::path::PathBuf, + } + match agent.post(&url).set("Authorization", &format!("Bearer {}", s.token)).set("Content-Length", &len.to_string()).send(data) { + Ok(r) => r.into_json::().map(|p| p.path).map_err(|e| e.to_string()), + Err(ureq::Error::Status(code, r)) => Err(r + .into_json::() + .ok() + .and_then(|v| v["error"].as_str().map(str::to_string)) + .unwrap_or_else(|| format!("HTTP {code}"))), + Err(e) => Err(e.to_string()), + } + } +} + // ------------------------------------------------------------ audio /// How much of the server's mix is buffered before it plays (decoded MP3 frames of about 26 ms). @@ -580,6 +701,12 @@ impl Syncer { self.rev = 0; self.clash("A playlist"); } + Err(Fail::Denied(m)) => { + // not allowed: the server's copy comes back + self.tabs_hash = None; + self.rev = 0; + self.core.lock().say(format!("Not changed on the server: {m}")); + } Err(e) => return Err(e), } } @@ -597,6 +724,11 @@ impl Syncer { self.cfg_rev = 0; self.clash("The settings"); } + Err(Fail::Denied(m)) => { + self.cfg_hash = None; + self.cfg_rev = 0; + self.core.lock().say(format!("Not changed on the server: {m}")); + } Err(e) => return Err(e), } } @@ -606,14 +738,21 @@ impl Syncer { self.queued.extend(self.cmds.try_iter()); if !self.queued.is_empty() { let cmds = std::mem::take(&mut self.queued); - if let Err(e) = self.post::("cmd", serde_json::to_value(&cmds).unwrap_or_default()) { - match e { - // signed out: they go once the app has signed in again - Fail::Auth(_) => self.queued = cmds, - // an action that couldn't be sent now is dropped (pressing Next a minute late would surprise) - _ => self.core.lock().say(format!("Not sent to the server: {} action(s)", cmds.len())), + match self.post::("cmd", serde_json::to_value(&cmds).unwrap_or_default()) { + Ok(r) => { + if r["refused"].as_u64().is_some_and(|n| n > 0) { + self.core.lock().say("Your server account may not do that"); + } + } + Err(e) => { + match e { + // signed out: they go once the app has signed in again + Fail::Auth(_) => self.queued = cmds, + // an action that couldn't be sent now is dropped (pressing Next a minute late would surprise) + _ => self.core.lock().say(format!("Not sent to the server: {} action(s)", cmds.len())), + } + return Err(e); } - return Err(e); } } // 3. what the server has now diff --git a/src/panes.rs b/src/panes.rs index 357f30b..bffc5ca 100644 --- a/src/panes.rs +++ b/src/panes.rs @@ -156,7 +156,7 @@ impl App { Tab::Queue => self.tab_queue(ui, core), Tab::Search => self.tab_search(ui, core), Tab::Statistics => self.tab_statistics(ui, core), - Tab::FileExplorer => self.tab_file_explorer(ui, pane, core), + Tab::FileExplorer => self.tab_file_explorer(ui, pane.key(), core), Tab::Log => self.tab_log(ui, core), Tab::SongRequests => self.tab_song_requests(ui, core), Tab::TrackInfo => self.tab_track_info(ui, core), @@ -501,9 +501,14 @@ impl App { } // ------------------------------------------------------- file explorer - fn tab_file_explorer(&mut self, ui: &mut egui::Ui, pane: Pane, core: &mut Playout) { - let key = pane.key(); + /// The file explorer, in a panel or its own window (`key` tells them apart). While connected to a + /// server it shows the server's folders. + pub(super) fn tab_file_explorer(&mut self, ui: &mut egui::Ui, key: String, core: &mut Playout) { + let fs = self.fs(); let start = || { + if fs.is_server() { + return PathBuf::new(); // the server says where to start + } core.cfg .library_folders .first() @@ -513,55 +518,66 @@ impl App { .unwrap_or_else(|| PathBuf::from("/")) }; let dir = self.fx_dir.entry(key.clone()).or_insert_with(start).clone(); + let all = self.fx_all.contains(&key); + let listed = self.fx_list.get(&key).filter(|(d, _, _)| *d == dir).map(|(_, _, l)| l.clone()); let mut go: Option = None; ui.horizontal_wrapped(|ui| { if ui.small_button("⬆").on_hover_text(tr("Up one folder")).clicked() { - if let Some(p) = dir.parent() { - go = Some(p.to_path_buf()); - } - } - for root in crate::library::drive_roots() { - if ui.small_button(root.display().to_string().trim_end_matches('\\')).clicked() { + go = listed.as_ref().and_then(|l| l.parent.clone()).or_else(|| dir.parent().map(|p| p.to_path_buf())); + } + let places = match &listed { + Some(l) => l.places.clone(), + None if fs.is_server() => vec![], + None => crate::library::drive_roots().to_vec(), + }; + for root in places { + let label = root.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| root.display().to_string()); + if ui.small_button(label.trim_end_matches('\\')).on_hover_text(root.display().to_string()).clicked() { go = Some(root.clone()); } } if ui.add_enabled(self.perm.edit_playlists, egui::Button::new(tr("Add folder to playlist")).small()).clicked() { self.add_files_later(self.view_pl, Some(usize::MAX), vec![dir.clone()]); } + let mut a = all; + if ui.checkbox(&mut a, tr("All files")).on_hover_text(tr("Show every file in the folder, not only audio")).changed() { + if a { + self.fx_all.insert(key.clone()); + } else { + self.fx_all.remove(&key); + } + self.fx_list.remove(&key); + } }); - ui.label(RichText::new(dir.display().to_string()).small().color(GRAY)); + let where_ = if fs.is_server() { format!("{} {}", tr("Server:"), dir.display()) } else { dir.display().to_string() }; + ui.label(RichText::new(where_).small().color(GRAY)); ui.separator(); // the folder is listed when it changes, on Refresh, or every few seconds — not every frame, and - // off the UI thread (network and cloud drives can take seconds to answer) + // off the UI thread (network and cloud drives, and servers, can take seconds to answer) let stale = self.fx_list.get(&key).is_none_or(|(d, t, _)| *d != dir || t.elapsed() > Duration::from_secs(5)); if stale && self.fx_loading.get(&key) != Some(&dir) { self.fx_loading.insert(key.clone(), dir.clone()); - let (k, d) = (key.clone(), dir.clone()); + let (k, d, folders) = (key.clone(), dir.clone(), core.cfg.library_folders.clone()); self.dlg(move || { - let mut list: Vec<(bool, PathBuf)> = std::fs::read_dir(&d) - .map(|rd| { - rd.filter_map(|e| e.ok()) - .map(|e| (e.file_type().map(|t| t.is_dir()).unwrap_or(false), e.path())) - .filter(|(d, p)| *d || crate::library::is_audio(p)) - .collect() - }) - .unwrap_or_default(); - list.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.file_name().cmp(&b.1.file_name()))); + let list = fs.list(&d, all, &folders); Some(super::Dlg::FolderList(k, d, list)) }); } - let listing = self.fx_list.get(&key).is_none_or(|(d, _, _)| *d != dir); + let listing = listed.is_none(); if listing { ui.spinner(); } - let entries = self.fx_list.get(&key).filter(|(d, _, _)| *d == dir).map(|(_, _, l)| l.clone()).unwrap_or_default(); + let list = listed.unwrap_or_default(); + if !list.error.is_empty() { + ui.colored_label(RED, &list.error); + } let mut add: Vec = vec![]; // only the rows in view are drawn; the others just take their space (big folders stay smooth) let row_h = (ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y) .max(ui.spacing().interact_size.y) + ui.spacing().item_spacing.y; let mut skipped = 0.0; - for (is_dir, p) in entries.iter().take(2000) { + for e in list.entries.iter().take(5000) { let row = egui::Rect::from_min_size(ui.cursor().min + egui::vec2(0.0, skipped), egui::vec2(1.0, row_h)); if !ui.is_rect_visible(row) { skipped += row_h; @@ -571,15 +587,29 @@ impl App { ui.add_space(skipped); skipped = 0.0; } + let p = &e.path; + let audio = e.dir || crate::library::is_audio(p); let name = p.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - let r = - ui.selectable_label(false, if *is_dir { format!("📁 {name}") } else { format!("🎵 {name}") }).interact(egui::Sense::drag()); + let text = if e.dir { + RichText::new(format!("📁 {name}")) + } else if audio { + RichText::new(format!("🎵 {name}")) + } else { + RichText::new(format!(" {name}")).color(GRAY) + }; + let r = ui.selectable_label(false, text).interact(egui::Sense::drag()); + if !e.dir { + r.clone().on_hover_text(format!("{} · {}", name, size_text(e.size))); + } + if !audio { + continue; // shown, but not something a playlist can play + } if r.drag_started() && self.perm.edit_playlists { // a folder is listed when it is dropped, off the UI thread r.dnd_set_drag_payload(super::plview::DragFiles(vec![p.clone()])); } if r.double_clicked() { - if *is_dir { + if e.dir { go = Some(p.clone()); } else { add.push(p.clone()); @@ -594,7 +624,7 @@ impl App { add.push(p.clone()); ui.close_menu(); } - if !*is_dir && ui.button(tr("Insert after selected row")).clicked() { + if !e.dir && ui.button(tr("Insert after selected row")).clicked() { let at = self.insert_pos(core); let rows: Vec = vec![core.entry_quick(p)]; core.with_tab(self.view_pl, |c| c.insert_rows(at, rows)); @@ -605,8 +635,8 @@ impl App { if skipped > 0.0 { ui.add_space(skipped); } - if entries.is_empty() && !listing { - ui.label(RichText::new(tr("No folders or audio files here.")).color(GRAY)); + if list.entries.is_empty() && !listing && list.error.is_empty() { + ui.label(RichText::new(if all { tr("This folder is empty.") } else { tr("No folders or audio files here.") }).color(GRAY)); } if !add.is_empty() && self.perm.edit_playlists { self.add_files_later(self.view_pl, Some(usize::MAX), add); @@ -815,3 +845,13 @@ pub(super) fn row_background(ui: &mut egui::Ui, e: &PlEntry, is_cur: bool) { ui.painter().rect_filled(ui.max_rect().expand2(egui::vec2(4.0, 1.0)), 0.0, c); } } + +/// "3.2 MB" +fn size_text(bytes: u64) -> String { + match bytes { + b if b >= 1 << 30 => format!("{:.1} GB", b as f64 / (1u64 << 30) as f64), + b if b >= 1 << 20 => format!("{:.1} MB", b as f64 / (1u64 << 20) as f64), + b if b >= 1 << 10 => format!("{} KB", b >> 10), + b => format!("{b} bytes"), + } +} diff --git a/src/playout.rs b/src/playout.rs index ed1e47f..4273a5f 100644 --- a/src/playout.rs +++ b/src/playout.rs @@ -278,6 +278,7 @@ pub fn marker_target(hour: u32, minute: u32, reference: DateTime) -> Date } /// A line of an M3U: a RadioMaster special row, or a file that exists (and whether it is a voice track). +#[derive(Serialize, Deserialize)] pub enum M3uRow { Row(PlEntry), File(PathBuf, bool), diff --git a/src/pltools.rs b/src/pltools.rs index e360cca..8b2d3ad 100644 --- a/src/pltools.rs +++ b/src/pltools.rs @@ -435,7 +435,7 @@ impl App { ui.horizontal_wrapped(|ui| { if action_edit(ui, "item_event", &mut d.action, &mut d.text, &mut d.arg, &opts) { let a = d.action; - self.dlg(move || action_file_dialog(a).pick_file().map(Dlg::EventPath)); + self.pick(action_pick(a), tr("Choose a file"), |_, v| Some(Dlg::EventPath(v.into_iter().next()?))); } }); ui.end_row(); @@ -484,7 +484,7 @@ impl App { ui.horizontal(|ui| { ui.add(egui::TextEdit::singleline(&mut a.folder).hint_text(tr("optional folder")).desired_width(220.0)); if ui.button("…").clicked() { - self.dlg(|| rfd::FileDialog::new().pick_folder().map(Dlg::AnnounceFolder)); + self.pick(Pick::Folder, tr("Recordings folder"), |_, v| Some(Dlg::AnnounceFolder(v.into_iter().next()?))); } }); ui.end_row(); @@ -522,7 +522,7 @@ impl App { d.items.remove(k); } if ui.button(tr("Add files…")).clicked() { - self.dlg(|| rfd::FileDialog::new().add_filter("Audio", &crate::library::EXTS).pick_files().map(Dlg::ContainerFiles)); + self.pick(Pick::Files(AUDIO.0, AUDIO.1), tr("Add files"), |_, v| Some(Dlg::ContainerFiles(v))); } }); ui.end_row(); @@ -533,10 +533,10 @@ impl App { ui.horizontal(|ui| { ui.add(egui::TextEdit::singleline(&mut d.text).hint_text(tr("folder or .m3u")).desired_width(200.0)); if ui.button(tr("Folder…")).clicked() { - self.dlg(|| rfd::FileDialog::new().pick_folder().map(Dlg::TrackListSource)); + self.pick(Pick::Folder, tr("Track list folder"), |_, v| Some(Dlg::TrackListSource(v.into_iter().next()?))); } if ui.button(tr("M3U…")).clicked() { - self.dlg(|| rfd::FileDialog::new().add_filter("Playlist", &["m3u", "m3u8"]).pick_file().map(Dlg::TrackListSource)); + self.pick(Pick::File(M3U.0, M3U.1), tr("Track list playlist"), |_, v| Some(Dlg::TrackListSource(v.into_iter().next()?))); } }); ui.end_row(); @@ -636,9 +636,7 @@ impl App { ui.horizontal(|ui| { ui.add(egui::TextEdit::singleline(path).hint_text(tr("none")).desired_width(230.0)); if ui.small_button("…").on_hover_text(tr("Pick a sound file")).clicked() { - self.dlg(move || { - rfd::FileDialog::new().add_filter("Audio", &crate::library::EXTS).pick_file().map(|p| Dlg::SpeechSound(outro, p)) - }); + self.pick(Pick::File(AUDIO.0, AUDIO.1), tr("Pick a sound file"), move |_, v| Some(Dlg::SpeechSound(outro, v.into_iter().next()?))); } if !path.is_empty() && ui.small_button("✖").on_hover_text(tr("No sound")).clicked() { path.clear(); @@ -850,6 +848,19 @@ impl App { ui.small(tr("Recordings are saved as WAV in the voicetracks folder of your RadioMaster data folder.")); }, ); + if let Some(f) = insert.take().filter(|_| self.srv.connected()) { + // the server plays it: the recording goes to a folder there first + self.pick(Pick::Folder, tr("Save the voice track on the server in"), move |fs, v| { + let dir = v.into_iter().next()?; + Some(match fs.upload(&f, &dir) { + Ok(p) => Dlg::InsertVoice(vec![p]), + Err(e) => Dlg::Say(format!("The voice track couldn't be uploaded: {e}")), + }) + }); + if let Some(v) = self.vt.as_mut() { + v.file = None; + } + } if let Some(f) = insert { let mut e = core.entry_for(&f); e.kind = ItemKind::VoiceTrack; @@ -965,10 +976,10 @@ impl App { } ui.separator(); if item(ui, edit, tr("Add file…"), &k("add_files")) { - self.dlg(|| rfd::FileDialog::new().add_filter("Audio", &crate::library::EXTS).pick_files().map(Dlg::AddFiles)); + self.pick(Pick::Files(AUDIO.0, AUDIO.1), tr("Add files"), |_, v| Some(Dlg::AddFiles(v))); } if item(ui, edit, tr("Add folder…"), &k("add_folder")) { - self.dlg(|| rfd::FileDialog::new().pick_folder().map(Dlg::add_folder)); + self.pick(Pick::Folder, tr("Add folder"), |fs, v| Some(Dlg::add_folder(fs, v.into_iter().next()?))); } if item(ui, edit, tr("Add URL…"), &k("add_url")) { self.open_item_dlg(DlgKind::Url); @@ -1002,7 +1013,7 @@ impl App { } ui.separator(); if item(ui, edit, tr("Insert voice track…"), &k("insert_voice")) { - self.dlg(|| rfd::FileDialog::new().add_filter("Audio", &crate::library::EXTS).pick_files().map(Dlg::InsertVoice)); + self.pick(Pick::Files(AUDIO.0, AUDIO.1), tr("Insert voice track"), |_, v| Some(Dlg::InsertVoice(v))); } if item(ui, vt, tr("🔴 Record voice track…"), &k("record_voice")) { self.show_vt = true; @@ -1016,10 +1027,12 @@ impl App { } ui.separator(); if item(ui, edit, tr("Insert playlist"), "") { - self.dlg(|| rfd::FileDialog::new().add_filter("Playlist", &["m3u", "m3u8"]).pick_file().map(Dlg::insert_playlist)); + self.pick(Pick::File(M3U.0, M3U.1), tr("Insert playlist"), |fs, v| Some(Dlg::insert_playlist(fs, v.into_iter().next()?))); } if item(ui, edit, tr("Insert tracks from playlist…"), "") { - self.dlg(|| rfd::FileDialog::new().add_filter("Playlist", &["m3u", "m3u8"]).pick_file().map(Dlg::insert_from_playlist)); + self.pick(Pick::File(M3U.0, M3U.1), tr("Insert tracks from playlist"), |fs, v| { + Some(Dlg::insert_from_playlist(fs, v.into_iter().next()?)) + }); } ui.separator(); ui.menu_button(tr("Extra"), |ui| { diff --git a/src/plview.rs b/src/plview.rs index dba4173..e065585 100644 --- a/src/plview.rs +++ b/src/plview.rs @@ -209,7 +209,7 @@ impl App { } TabOp::LoadM3u(i) => { self.view_pl = i; - self.dlg(|| rfd::FileDialog::new().add_filter("Playlist", &["m3u", "m3u8"]).pick_file().map(Dlg::load_m3u)); + self.pick(Pick::File(M3U.0, M3U.1), tr("Open playlist"), |fs, v| Some(Dlg::load_m3u(fs, v.into_iter().next()?))); } TabOp::Rows(i, rows) => core.append_to_tab(i, rows), TabOp::CopyInto(from, rows, to, at) => { diff --git a/src/reportui.rs b/src/reportui.rs index 2968676..04ec6e0 100644 --- a/src/reportui.rs +++ b/src/reportui.rs @@ -105,6 +105,13 @@ impl Default for RepUi { } impl RepUi { + /// Read the history again next time (it is another station's after connecting or disconnecting). + pub(super) fn forget(&mut self) { + self.loaded = false; + self.all.clear(); + self.key.clear(); + } + /// The history file, read in the background. pub(super) fn set_history(&mut self, all: Vec) { self.all = all; @@ -207,7 +214,17 @@ impl App { if !self.rep.loaded && !self.rep.loading { // a station's history grows for years: read it off the UI thread self.rep.loading = true; - self.dlg(|| Some(super::Dlg::History(report::load()))); + // while connected, the server's history (the station plays there) + let server = self.srv.session(); + self.dlg(move || { + Some(super::Dlg::History(match server { + Some(s) => match crate::link::Link::plays(&s) { + Ok(csv) => report::parse(&csv), + Err(_) => vec![], + }, + None => report::load(), + })) + }); } // ---- what ui.horizontal_wrapped(|ui| { diff --git a/src/selfupdate.rs b/src/selfupdate.rs new file mode 100644 index 0000000..6d10962 --- /dev/null +++ b/src/selfupdate.rs @@ -0,0 +1,341 @@ +//! RadioMaster Server updating itself: asks GitHub for the latest release, downloads the server +//! package for this system, puts the new program in place of the running one and starts again. + +use serde::Deserialize; +use std::{io::Read, path::PathBuf, time::Duration}; + +pub const REPO: &str = "epell-development/RadioMaster"; +pub const CURRENT: &str = env!("CARGO_PKG_VERSION"); + +/// "v0.2.10" → [0, 2, 10] +pub fn parse_version(v: &str) -> Vec { + v.trim().trim_start_matches(['v', 'V']).split(['.', '-', '+']).take(3).map(|p| p.parse().unwrap_or(0)).collect() +} + +pub fn is_newer(latest: &str, current: &str) -> bool { + let (mut a, mut b) = (parse_version(latest), parse_version(current)); + a.resize(3, 0); + b.resize(3, 0); + a > b +} + +#[derive(Deserialize, Clone, Debug, Default)] +#[serde(default)] +pub struct Asset { + pub name: String, + pub browser_download_url: String, + pub size: u64, + /// "sha256:…", as GitHub works it out for each release file + pub digest: String, +} + +#[derive(Deserialize, Clone, Debug, Default)] +#[serde(default)] +pub struct Release { + pub tag_name: String, + pub html_url: String, + pub draft: bool, + pub prerelease: bool, + pub assets: Vec, +} + +impl Release { + pub fn version(&self) -> String { + self.tag_name.trim_start_matches(['v', 'V']).to_string() + } + + /// The server package for this system (None: there is none, e.g. on macOS). + pub fn server_asset(&self) -> Option<&Asset> { + let suffix = package_suffix()?; + self.assets.iter().find(|a| a.name.starts_with("RadioMaster-Server-") && a.name.ends_with(suffix)) + } +} + +/// The end of the server package's name for this system. +fn package_suffix() -> Option<&'static str> { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => Some("-linux-x64.tar.gz"), + ("windows", "x86_64") => Some("-windows-x64.zip"), + _ => None, + } +} + +/// The program's file name inside the package. +fn program_name() -> &'static str { + if cfg!(windows) { + "RadioMaster-Server.exe" + } else { + "RadioMaster-Server" + } +} + +fn agent() -> ureq::Agent { + ureq::AgentBuilder::new().timeout_connect(Duration::from_secs(10)).timeout_read(Duration::from_secs(60)).build() +} + +/// The latest published release (blocks). RADIOMASTER_UPDATE_URL points it at a test server. +pub fn latest() -> Result { + let url = std::env::var("RADIOMASTER_UPDATE_URL").unwrap_or_else(|_| format!("https://api.github.com/repos/{REPO}/releases/latest")); + let r: Release = agent() + .get(&url) + .set("User-Agent", &format!("RadioMaster-Server/{CURRENT}")) + .set("Accept", "application/vnd.github+json") + .call() + .map_err(|e| match e { + ureq::Error::Status(404, _) => "no release has been published yet".to_string(), + e => e.to_string(), + })? + .into_json() + .map_err(|e| e.to_string())?; + if r.draft || r.prerelease { + return Err("the latest release is not final".into()); + } + Ok(r) +} + +/// Download `asset` and put its program in place of the one running (blocks). `progress` gets a +/// short status line now and then. The running program carries on until it is started again. +pub fn install(asset: &Asset, progress: &dyn Fn(String)) -> Result<(), String> { + if asset.size > DOWNLOAD_MAX { + return Err(format!("{} is too big to be RadioMaster Server ({} MB)", asset.name, asset.size >> 20)); + } + let resp = agent() + .get(&asset.browser_download_url) + .set("User-Agent", &format!("RadioMaster-Server/{CURRENT}")) + .call() + .map_err(|e| e.to_string())?; + let total = resp.header("Content-Length").and_then(|v| v.parse().ok()).unwrap_or(asset.size).max(1); + let mut reader = resp.into_reader().take(DOWNLOAD_MAX + 1); + let mut data = Vec::with_capacity(total.min(DOWNLOAD_MAX) as usize); + let mut buf = [0u8; 64 * 1024]; + let mut shown = 0; + loop { + let n = reader.read(&mut buf).map_err(|e| e.to_string())?; + if n == 0 { + break; + } + data.extend_from_slice(&buf[..n]); + let pct = data.len() as u64 * 100 / total; + if pct >= shown + 10 { + shown = pct; + progress(format!("Downloading {}… {pct}%", asset.name)); + } + } + check_download(asset, &data)?; + progress("Installing…".into()); + let program = if asset.name.ends_with(".zip") { unzip(&data, program_name()) } else { untar_gz(&data, program_name()) } + .ok_or_else(|| format!("{} has no {} in it", asset.name, program_name()))?; + let looks_right = if cfg!(windows) { program.starts_with(b"MZ") } else { program.starts_with(b"\x7fELF") }; + if program.len() < 512 * 1024 || !looks_right { + return Err("the download doesn't look like RadioMaster Server".into()); + } + replace_program(&program) +} + +/// The biggest release file taken (it's read into memory; the server's are tens of MB). +const DOWNLOAD_MAX: u64 = 200 * 1024 * 1024; + +/// The download is the release file whole and unchanged: its size and the SHA-256 the release +/// lists (GitHub gives every file one; without it, nothing is installed). +fn check_download(asset: &Asset, data: &[u8]) -> Result<(), String> { + use sha2::Digest; + if asset.size > 0 && data.len() as u64 != asset.size { + return Err(format!("the download of {} was cut short ({} of {} bytes)", asset.name, data.len(), asset.size)); + } + let Some(want) = asset.digest.strip_prefix("sha256:") else { + return Err(format!("the release lists no SHA-256 for {}, so it can't be checked: update by hand", asset.name)); + }; + let got: String = sha2::Sha256::digest(data).iter().map(|b| format!("{b:02x}")).collect(); + if !got.eq_ignore_ascii_case(want) { + return Err(format!("{} isn't the file the release lists (its SHA-256 differs)", asset.name)); + } + Ok(()) +} + +/// The file called `name` (in any folder) in a .tar.gz. +fn untar_gz(data: &[u8], name: &str) -> Option> { + let mut tar = Vec::new(); + flate2::read::GzDecoder::new(data).take(1024 * 1024 * 1024).read_to_end(&mut tar).ok()?; + let mut at = 0; + while at + 512 <= tar.len() { + let h = &tar[at..at + 512]; + if h.iter().all(|&b| b == 0) { + break; + } + let field = |r: std::ops::Range| String::from_utf8_lossy(&h[r]).trim_end_matches(['\0', ' ']).to_string(); + let size = u64::from_str_radix(field(124..136).trim(), 8).ok()? as usize; + let mut path = field(0..100); + let prefix = field(345..500); + if !prefix.is_empty() { + path = format!("{prefix}/{path}"); + } + let start = at + 512; + if h[156] != b'5' && path.rsplit('/').next() == Some(name) { + return tar.get(start..start + size).map(|b| b.to_vec()); + } + at = start + size.div_ceil(512) * 512; + } + None +} + +/// The file called `name` (in any folder) in a .zip, read through its central directory. +fn unzip(data: &[u8], name: &str) -> Option> { + let u16_at = |i: usize| data.get(i..i + 2).map(|b| u16::from_le_bytes([b[0], b[1]]) as usize); + let u32_at = |i: usize| data.get(i..i + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize); + // the end-of-central-directory record is in the last 64 KB + let from = data.len().saturating_sub(65_557); + let eocd = (from..data.len().saturating_sub(21)).rev().find(|&i| data[i..i + 4] == [0x50, 0x4b, 0x05, 0x06])?; + let (count, mut at) = (u16_at(eocd + 10)?, u32_at(eocd + 16)?); + for _ in 0..count { + if data.get(at..at + 4)? != [0x50, 0x4b, 0x01, 0x02] { + return None; + } + let (method, csize, size) = (u16_at(at + 10)?, u32_at(at + 20)?, u32_at(at + 24)?); + let (nlen, xlen, clen, local) = (u16_at(at + 28)?, u16_at(at + 30)?, u16_at(at + 32)?, u32_at(at + 42)?); + let path = String::from_utf8_lossy(data.get(at + 46..at + 46 + nlen)?).replace('\\', "/"); + if path.rsplit('/').next() == Some(name) { + let start = local + 30 + u16_at(local + 26)? + u16_at(local + 28)?; + let raw = data.get(start..start + csize)?; + return match method { + 0 => Some(raw.to_vec()), + 8 => { + let mut out = Vec::with_capacity(size); + flate2::read::DeflateDecoder::new(raw).take(size as u64).read_to_end(&mut out).ok()?; + (out.len() == size).then_some(out) + } + _ => None, + }; + } + at += 46 + nlen + xlen + clen; + } + None +} + +/// Put `program` where the running program is. (Windows lets a running program be renamed, not +/// overwritten; elsewhere the new file is renamed over the old one.) +fn replace_program(program: &[u8]) -> Result<(), String> { + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let denied = |e: std::io::Error| { + if e.kind() == std::io::ErrorKind::PermissionDenied { + format!( + "the server's account may not change {} — give it that file (e.g. chown it to the service account) or update by hand", + exe.display() + ) + } else { + format!("{}: {e}", exe.display()) + } + }; + let new = exe.with_extension("new"); + std::fs::write(&new, program).map_err(denied)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&new, std::fs::Permissions::from_mode(0o755)).map_err(denied)?; + } + if cfg!(windows) { + let old = exe.with_extension("old.exe"); + let _ = std::fs::remove_file(&old); + std::fs::rename(&exe, &old).map_err(denied)?; + if let Err(e) = std::fs::rename(&new, &exe) { + let _ = std::fs::rename(&old, &exe); + return Err(denied(e)); + } + } else { + std::fs::rename(&new, &exe).map_err(denied)?; + } + Ok(()) +} + +/// A service manager starts the server again when it ends with `RESTART_EXIT` (systemd sets +/// INVOCATION_ID; set RADIOMASTER_SUPERVISED=1 for others, such as NSSM). +pub fn supervised() -> bool { + std::env::var_os("INVOCATION_ID").is_some() || std::env::var("RADIOMASTER_SUPERVISED").is_ok_and(|v| v == "1") +} + +/// The exit code that asks the service manager for a restart (any failure code does). +pub const RESTART_EXIT: i32 = 75; + +/// Set in a copy started by `start_again`: it waits for the old one to let go of the ports. +pub const RESTARTED_ENV: &str = "RADIOMASTER_RESTARTED"; + +/// Start a new copy of this program with the same options (this one then exits). +pub fn start_again() -> Result<(), String> { + let exe: PathBuf = std::env::current_exe().map_err(|e| e.to_string())?; + std::process::Command::new(exe).args(std::env::args_os().skip(1)).env(RESTARTED_ENV, "1").spawn().map(|_| ()).map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn downloads_are_checked_against_the_release() { + // SHA-256 of "abc" + let digest = "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad".to_string(); + let asset = Asset { name: "x.tar.gz".into(), size: 3, digest, ..Default::default() }; + assert!(check_download(&asset, b"abc").is_ok()); + assert!(check_download(&asset, b"abd").unwrap_err().contains("SHA-256")); + assert!(check_download(&asset, b"ab").unwrap_err().contains("cut short")); + assert!(check_download(&Asset::default(), b"anything").unwrap_err().contains("no SHA-256")); + } + + #[test] + fn versions_compare_numerically() { + assert!(is_newer("v0.2.10", "0.2.9")); + assert!(!is_newer("v0.2.9", "0.2.9")); + assert!(!is_newer("0.2.0", "0.2.1")); + } + + #[test] + fn finds_the_program_in_a_tar_gz() { + let mut tar = Vec::new(); + for (name, body) in [("pkg/README.md", &b"hello"[..]), ("pkg/RadioMaster-Server", &b"\x7fELF program"[..])] { + let mut h = [0u8; 512]; + h[..name.len()].copy_from_slice(name.as_bytes()); + h[124..135].copy_from_slice(format!("{:011o}", body.len()).as_bytes()); + h[156] = b'0'; + tar.extend_from_slice(&h); + tar.extend_from_slice(body); + tar.resize(tar.len().div_ceil(512) * 512, 0); + } + tar.extend_from_slice(&[0u8; 1024]); + let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + gz.write_all(&tar).unwrap(); + let data = gz.finish().unwrap(); + assert_eq!(untar_gz(&data, "RadioMaster-Server").unwrap(), b"\x7fELF program"); + assert!(untar_gz(&data, "missing").is_none()); + } + + #[test] + fn finds_the_program_in_a_zip() { + // one deflated entry, written the way zip tools lay it out + let body = b"MZ the program, the program, the program"; + let mut packed = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + packed.write_all(body).unwrap(); + let packed = packed.finish().unwrap(); + let name = b"pkg\\RadioMaster-Server.exe"; + let mut z = Vec::new(); + z.extend_from_slice(&[0x50, 0x4b, 0x03, 0x04, 20, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + z.extend_from_slice(&(packed.len() as u32).to_le_bytes()); + z.extend_from_slice(&(body.len() as u32).to_le_bytes()); + z.extend_from_slice(&(name.len() as u16).to_le_bytes()); + z.extend_from_slice(&0u16.to_le_bytes()); + z.extend_from_slice(name); + z.extend_from_slice(&packed); + let cd = z.len(); + z.extend_from_slice(&[0x50, 0x4b, 0x01, 0x02, 20, 0, 20, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + z.extend_from_slice(&(packed.len() as u32).to_le_bytes()); + z.extend_from_slice(&(body.len() as u32).to_le_bytes()); + z.extend_from_slice(&(name.len() as u16).to_le_bytes()); + z.extend_from_slice(&[0; 12]); + z.extend_from_slice(&0u32.to_le_bytes()); + z.extend_from_slice(name); + let cd_len = z.len() - cd; + z.extend_from_slice(&[0x50, 0x4b, 0x05, 0x06, 0, 0, 0, 0, 1, 0, 1, 0]); + z.extend_from_slice(&(cd_len as u32).to_le_bytes()); + z.extend_from_slice(&(cd as u32).to_le_bytes()); + z.extend_from_slice(&0u16.to_le_bytes()); + assert_eq!(unzip(&z, "RadioMaster-Server.exe").unwrap(), body); + } +} diff --git a/src/server.rs b/src/server.rs index 91be5fd..2bbeb25 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,7 +1,7 @@ //! RadioMaster Server's HTTP API: what the desktop app's File → Connect to Server talks to. //! //! GET /api/v1/ping who is this (no sign-in needed) -//! POST /api/v1/login {user, password} → {token, name, version, protocol} +//! POST /api/v1/login {user, password} → {token, name, version, protocol, admin, perms} //! POST /api/v1/logout //! GET /api/v1/state?rev=&cfg=&log=&hist=&miss= → sync::Snapshot (lists only when they changed) //! POST /api/v1/wave {path} → sync::WaveReply (202 while the server analyses it) @@ -11,20 +11,33 @@ //! POST /api/v1/config {base, cfg} → {rev}, or 409 //! GET /api/v1/server → sync::ServerInfo (name, sound cards, …) //! POST /api/v1/server sync::ServerUpdate → sync::ServerInfo +//! POST /api/v1/server/update download the latest release, install it and restart +//! POST /api/v1/server/restart | /stop +//! GET /api/v1/users → [sync::UserWire] +//! POST /api/v1/users sync::UserOp → [sync::UserWire] +//! POST /api/v1/fs/list {path, all} → sync::FsList (a folder on the server) +//! POST /api/v1/fs/expand [path] → [path]: the audio files, folders listed +//! POST /api/v1/fs/m3u {path} → [M3uRow] +//! GET /api/v1/fs/file?path= → the file itself +//! POST /api/v1/fs/upload?dir=&name= file bytes → {path} +//! GET /api/v1/plays → {csv}: the play history, for Reports //! GET /api/v1/listen → the on-air mix as an endless MP3 stream (what the app hears) //! POST /api/v1/mic MP3 bytes the app's mic, a slice at a time; heard on air until mic/off //! POST /api/v1/mic/off //! POST /api/v1/azuracast {method, path, body} a call to the station's AzuraCast, with the server's key //! -//! Every call but ping and login needs `Authorization: Bearer `. +//! Every call but ping and login needs `Authorization: Bearer `. The accounts are the server's +//! operators.json (auth::Accounts); each call is checked against the signed-in account's permissions, +//! and only administrators change the server itself, its accounts and its program. //! //! With HTTPS on, the same API is served over TLS (see tls.rs), with a certificate that is given, made //! by the server, or got from Let's Encrypt (acme.rs). The optional web portal is a page on its own //! port that signs in and uses this same API from the browser; it can be put on the internet with a //! Cloudflare Tunnel (tunnel.rs). +use crate::auth::{Accounts, Operator, Perms}; use crate::config::data_dir; -use crate::playout::sync::{self, digest, Cmd, ServerInfo, ServerUpdate, Snapshot, TabsWire}; +use crate::playout::sync::{self, digest, Cmd, ServerInfo, ServerUpdate, Snapshot, TabsWire, UserOp, UserWire}; use crate::playout::Playout; use crossbeam_channel::{Receiver, RecvTimeoutError, Sender}; use parking_lot::Mutex; @@ -44,8 +57,11 @@ use std::{ pub struct ServerCfg { /// shown in the app when it connects pub name: String, + /// (before v0.2.9 the server had one account: it moves to operators.json as an administrator) + #[serde(skip_serializing_if = "String::is_empty")] pub user: String, /// Argon2id; the password itself is never stored + #[serde(skip_serializing_if = "String::is_empty")] pub password_hash: String, pub bind: String, pub port: u16, @@ -80,6 +96,8 @@ pub struct ServerCfg { pub tunnel_token: String, /// the cloudflared program (empty: next to the server, or on the PATH) pub cloudflared: String, + /// look for a newer RadioMaster Server release on GitHub (administrators can then update it from the app) + pub check_updates: bool, } impl Default for ServerCfg { @@ -106,6 +124,7 @@ impl Default for ServerCfg { tunnel: false, tunnel_token: String::new(), cloudflared: String::new(), + check_updates: true, } } } @@ -130,8 +149,29 @@ impl ServerCfg { } std::fs::write(Self::path(), serde_json::to_string_pretty(self).unwrap_or_default()) } + /// Someone can sign in as an administrator. pub fn ready(&self) -> bool { - !self.user.is_empty() && !self.password_hash.is_empty() + (!self.user.is_empty() && !self.password_hash.is_empty()) || Accounts::load().has_admin() + } + + /// The one account of a server set up before v0.2.9 becomes an administrator in `accounts`. + /// True when there was one to move. + pub fn move_user(&mut self, accounts: &mut Accounts) -> bool { + if self.user.is_empty() || self.password_hash.is_empty() { + return false; + } + if !accounts.users.iter().any(|u| u.name == self.user) { + accounts.users.push(Operator { + name: std::mem::take(&mut self.user), + password_hash: std::mem::take(&mut self.password_hash), + admin: true, + perms: Perms::default(), + disabled: false, + }); + } + self.user.clear(); + self.password_hash.clear(); + true } /// What to ask Let's Encrypt for (None: switched off, or no domain name). @@ -200,8 +240,17 @@ impl Revs { } } +/// Asked of the server program by an administrator. +pub enum Control { + Stop, + /// stop and start again (after an update: the new version) + Restart, +} + /// What the server program does for the API: its sound cards live on its main thread. pub struct Host { + /// stop or restart the whole server (saving everything first) + pub control: Box, /// open the sound cards again from `audio_devices` and `ServerCfg::audio` pub reapply_audio: Box, /// (device, status) as last opened @@ -210,13 +259,24 @@ pub struct Host { pub forced_silent: bool, } +/// The server's own update: the release found, and what is happening. +#[derive(Default)] +struct UpdateState { + latest: Option, + /// e.g. "Downloading… 40%" (empty: nothing going on) + state: String, + busy: bool, +} + struct Api { core: Arc>, cfg: Mutex, host: Host, revs: Mutex, + accounts: Mutex, /// token → (user, last used) tokens: Mutex>, + update: Mutex, failures: Mutex<(u32, Instant)>, /// what is running now (server.json may have changed since): (https, portal, portal port, Let's Encrypt) running: (bool, bool, u16, bool), @@ -231,8 +291,6 @@ struct Api { tunnel: Mutex>, tunnel_status: crate::tunnel::Shared, origin: String, - /// a newer RadioMaster release - updates: crate::updater::Shared, /// waveforms being worked out for apps' track editors waves: Arc>>, } @@ -286,8 +344,15 @@ const MIC_IDLE: Duration = Duration::from_secs(3); /// Mic audio waits for this many decoded MP3 frames (about 26 ms each) before it plays. const MIC_PREBUFFER: usize = 6; +/// How often the server looks for a newer release. +const UPDATE_EVERY: Duration = Duration::from_secs(6 * 3600); + /// Start listening. Returns the addresses actually bound (port 0 picks a free one). -pub fn spawn(core: Arc>, cfg: ServerCfg, host: Host) -> Result { +pub fn spawn(core: Arc>, mut cfg: ServerCfg, host: Host) -> Result { + let mut accounts = Accounts::load(); + if cfg.move_user(&mut accounts) { + accounts.save()?; + } let tls = if cfg.https { Some(crate::tls::server_tls(&cfg.cert_file, &cfg.key_file, &data_dir(), cfg.acme)?) } else { None }; let (server, addr) = listen(&cfg.bind, cfg.port, tls.as_ref())?; let portal = if cfg.portal { @@ -318,11 +383,14 @@ pub fn spawn(core: Arc>, cfg: ServerCfg, host: Host) -> Result
  • >, cfg: ServerCfg, host: Host) -> Result
  • >, cfg: ServerCfg, host: Host) -> Result
  • >, cfg: ServerCfg, host: Host) -> Result
  • { + let mut u = api.update.lock(); + if crate::selfupdate::is_newer(&r.tag_name, crate::selfupdate::CURRENT) + && u.latest.as_ref().is_none_or(|o| o.tag_name != r.tag_name) + { + api.core.lock().say(format!( + "Server: RadioMaster Server {} is available (this is {})", + r.version(), + crate::selfupdate::CURRENT + )); + } + u.latest = Some(r); + } + Err(e) => api.core.lock().say(format!("Server: couldn't check for updates: {e}")), + } + thread::sleep(UPDATE_EVERY); + }); + } let portal_addr = portal.as_ref().map(|p| p.1); let mut servers = vec![(Arc::new(server), false, 4)]; if let Some((s, _)) = portal { @@ -471,9 +547,7 @@ impl Api { return respond_file(req, data, ct, self.hsts()); } } - let param = |k: &str| -> u64 { - query.split('&').find_map(|kv| kv.split_once('=').filter(|(a, _)| *a == k).and_then(|(_, b)| b.parse().ok())).unwrap_or(0) - }; + let param = |k: &str| -> u64 { query_str(query, k).and_then(|v| v.parse().ok()).unwrap_or(0) }; let header = |name: &str| { req.headers().iter().find(|h| h.field.as_str().as_str().eq_ignore_ascii_case(name)).map(|h| h.value.as_str().to_string()) }; @@ -481,14 +555,34 @@ impl Api { let token = header("Authorization").and_then(|v| v.strip_prefix("Bearer ").map(|t| t.trim().to_string())); let post = *req.method() == tiny_http::Method::Post; if path == "/api/v1/listen" && !post { - match token.filter(|t| self.check(t)) { + match token.filter(|t| self.check(t).is_some()) { Some(t) => self.listen(req, t), None => respond(req, error(401, "sign in first"), false), } return; } + if path == "/api/v1/fs/file" && !post { + let me = token.as_deref().and_then(|t| self.check(t)); + return match me { + Some(me) => self.send_file(req, &me, &query_str(query, "path").unwrap_or_default()), + None => respond(req, error(401, "sign in first"), false), + }; + } + if path == "/api/v1/fs/upload" && post { + // (straight to disk, and only once signed in) + let me = token.as_deref().and_then(|t| self.check(t)); + let reply = match me { + Some(me) if me.effective().edit_playlists || me.effective().jingles => { + let (dir, name) = (query_str(query, "dir").unwrap_or_default(), query_str(query, "name").unwrap_or_default()); + self.upload(&dir, &name, req.as_reader(), &me) + } + Some(me) => error(403, &format!("your account ({}) may not upload files", me.name)), + None => error(401, "sign in first"), + }; + return respond(req, reply, false); + } let mut body = Vec::new(); - if req.as_reader().take(256 * 1024 * 1024).read_to_end(&mut body).is_err() { + if req.as_reader().take(64 * 1024 * 1024).read_to_end(&mut body).is_err() { return respond(req, error(400, "unreadable request"), false); } let reply = match (path, post) { @@ -497,9 +591,9 @@ impl Api { serde_json::json!({ "app": "RadioMaster Server", "name": self.cfg.lock().name, "version": env!("CARGO_PKG_VERSION"), "protocol": sync::PROTOCOL }), ), ("/api/v1/login", true) => self.login(&body), - _ => match token.as_deref().map(|t| self.check(t)) { - Some(true) => self.call(path, post, &body, ¶m, token.as_deref().unwrap_or("")), - _ => error(401, "sign in first"), + _ => match token.as_deref().and_then(|t| self.check(t)) { + Some(me) => self.call(path, post, &body, ¶m, token.as_deref().unwrap_or(""), &me), + None => error(401, "sign in first"), }, }; respond(req, reply, gzip_ok); @@ -517,17 +611,14 @@ impl Api { } }; thread::sleep(Duration::from_secs(wait as u64)); - let (user, hash) = { - let c = self.cfg.lock(); - (c.user.clone(), c.password_hash.clone()) - }; - if l.user != user || !crate::auth::verify(&hash, &l.password) { + let found = self.accounts.lock().login(&l.user, &l.password); + let Some(me) = found else { let mut f = self.failures.lock(); f.0 = if f.1.elapsed() > Duration::from_secs(600) { 1 } else { f.0 + 1 }; f.1 = Instant::now(); self.core.lock().say(format!("Server: failed sign-in as '{}'", l.user)); return error(401, "wrong username or password"); - } + }; *self.failures.lock() = (0, Instant::now()); let token: String = { use rand::Rng; @@ -536,27 +627,39 @@ impl Api { }; let mut t = self.tokens.lock(); t.retain(|_, (_, used)| used.elapsed() < TOKEN_IDLE); - t.insert(token.clone(), (l.user.clone(), Instant::now())); + t.insert(token.clone(), (me.name.clone(), Instant::now())); drop(t); - self.core.lock().say(format!("Server: {} connected", l.user)); + self.core.lock().say(format!("Server: {} connected", me.name)); json( 200, - serde_json::json!({ "ok": true, "token": token, "name": self.cfg.lock().name.clone(), "version": env!("CARGO_PKG_VERSION"), "protocol": sync::PROTOCOL }), + serde_json::json!({ "ok": true, "token": token, "name": self.cfg.lock().name.clone(), "version": env!("CARGO_PKG_VERSION"), + "protocol": sync::PROTOCOL, "admin": me.admin, "perms": me.effective() }), ) } - fn check(&self, token: &str) -> bool { + /// The account signed in with `token`, as it is now (None: not signed in, or the account was + /// deleted or disabled since). + fn check(&self, token: &str) -> Option { let mut t = self.tokens.lock(); - match t.get_mut(token) { - Some((_, used)) if used.elapsed() < TOKEN_IDLE => { + let user = match t.get_mut(token) { + Some((user, used)) if used.elapsed() < TOKEN_IDLE => { *used = Instant::now(); - true + user.clone() } - _ => false, + _ => return None, + }; + let me = self.accounts.lock().active(&user).cloned(); + if me.is_none() { + t.remove(token); } + me } - fn call(self: &Arc, path: &str, post: bool, body: &[u8], param: &dyn Fn(&str) -> u64, token: &str) -> Reply { + #[allow(clippy::too_many_arguments)] + fn call(self: &Arc, path: &str, post: bool, body: &[u8], param: &dyn Fn(&str) -> u64, token: &str, me: &Operator) -> Reply { + let p = me.effective(); + let files_ok = p.edit_playlists || p.jingles || p.settings || p.tool(crate::auth::tool::FILE_EXPLORER); + let denied = |what: &str| error(403, &format!("your account ({}) may not {what}", me.name)); match (path, post) { ("/api/v1/logout", true) => { if let Some((user, _)) = self.tokens.lock().remove(token) { @@ -594,12 +697,21 @@ impl Api { ("/api/v1/cmd", true) => { let Ok(cmds) = serde_json::from_slice::>(body) else { return error(400, "bad command") }; let mut c = self.core.lock(); + let mut refused = 0; for cmd in cmds { - c.run_cmd(cmd); + if cmd.allowed(&p) { + c.run_cmd(cmd); + } else { + refused += 1; + c.say(format!("Server: {} may not {cmd:?}", me.name)); + } } - json(200, serde_json::json!({ "ok": true })) + json(200, serde_json::json!({ "ok": true, "refused": refused })) } ("/api/v1/tabs", true) => { + if !p.edit_playlists { + return denied("change playlists"); + } let Ok(p) = serde_json::from_slice::(body) else { return error(400, "bad playlists") }; let mut r = self.revs.lock(); if p.base != r.rev { @@ -616,38 +728,273 @@ impl Api { json(200, serde_json::json!({ "ok": true, "rev": r.rev })) } ("/api/v1/config", true) => { - let Ok(p) = serde_json::from_slice::(body) else { return error(400, "bad settings") }; + let Ok(push) = serde_json::from_slice::(body) else { return error(400, "bad settings") }; let mut r = self.revs.lock(); - if p.base != r.cfg_rev { + if push.base != r.cfg_rev { return error(409, "the settings changed on the server"); } let mut c = self.core.lock(); - if digest(&sync::station_cfg(&c.cfg)) != r.cfg_hash { + let now = sync::station_cfg(&c.cfg); + if digest(&now) != r.cfg_hash { return error(409, "the settings changed on the server"); } - c.set_station_cfg(p.cfg); + if let Some(part) = sync::cfg_denied(&now, &sync::station_cfg(&push.cfg), &p) { + return denied(&format!("change these settings ({part})")); + } + c.set_station_cfg(push.cfg); c.save_all(); drop(c); r.refresh(&self.core); json(200, serde_json::json!({ "ok": true, "rev": r.cfg_rev })) } ("/api/v1/wave", true) => self.wave(body), + ("/api/v1/mic", true) if !p.playback => denied("use the mic"), ("/api/v1/mic", true) => self.mic(token, body.to_vec()), ("/api/v1/mic/off", true) => { // the mic's thread ends once its audio runs out self.mics.lock().remove(token); json(200, serde_json::json!({ "ok": true })) } + ("/api/v1/azuracast", true) if !p.azuracast => denied("use AzuraCast"), ("/api/v1/azuracast", true) => self.azuracast(body), - ("/api/v1/server", false) => json(200, self.info()), + ("/api/v1/server" | "/api/v1/server/update" | "/api/v1/server/restart" | "/api/v1/server/stop" | "/api/v1/users", _) + if !me.admin => + { + denied("manage the server") + } + ("/api/v1/server", false) => json(200, self.info(me)), ("/api/v1/server", true) => { let Ok(u) = serde_json::from_slice::(body) else { return error(400, "bad server settings") }; - self.update(u) + self.update(u, me) + } + ("/api/v1/server/update", true) => self.self_update(me), + ("/api/v1/server/restart", true) | ("/api/v1/server/stop", true) => { + let restart = path.ends_with("restart"); + self.core.lock().say(format!("Server: {} asked it to {}", me.name, if restart { "restart" } else { "stop" })); + self.update.lock().state = if restart { "Restarting…" } else { "Stopping…" }.into(); + let api = self.clone(); + // after this answer has gone out + thread::spawn(move || { + thread::sleep(Duration::from_millis(300)); + (api.host.control)(if restart { Control::Restart } else { Control::Stop }); + }); + json(200, self.info(me)) + } + ("/api/v1/users", false) => json(200, self.users()), + ("/api/v1/users", true) => { + let Ok(op) = serde_json::from_slice::(body) else { return error(400, "bad request") }; + match self.user_op(op, me) { + Ok(()) => json(200, self.users()), + Err(e) => error(400, &e), + } + } + ("/api/v1/fs/list" | "/api/v1/fs/expand" | "/api/v1/fs/m3u", true) if !files_ok => denied("browse the server's files"), + ("/api/v1/fs/list", true) => { + #[derive(Deserialize)] + struct Ask { + #[serde(default)] + path: std::path::PathBuf, + #[serde(default)] + all: bool, + } + let Ok(a) = serde_json::from_slice::(body) else { return error(400, "bad request") }; + let folders = self.core.lock().cfg.library_folders.clone(); + if p.settings { + return json(200, sync::list_folder(a.path, a.all, &folders)); + } + // everyone else stays in the library folders + let roots = self.library_roots(); + if roots.is_empty() { + return error(403, "the server has no library folders to browse yet: ask one of its administrators"); + } + if !a.path.as_os_str().is_empty() && !within(&roots, &a.path) { + return denied("browse outside the library folders"); + } + let mut list = sync::list_folder(a.path, a.all, &folders); + list.places.retain(|f| within(&roots, f)); + list.parent = list.parent.filter(|f| within(&roots, f)); + json(200, list) + } + ("/api/v1/fs/expand", true) => { + let Ok(mut paths) = serde_json::from_slice::>(body) else { return error(400, "bad request") }; + if !p.settings { + let roots = self.library_roots(); + paths.retain(|f| within(&roots, f)); + } + json(200, crate::library::expand_paths(paths)) + } + ("/api/v1/fs/m3u", true) => { + #[derive(Deserialize)] + struct Ask { + path: std::path::PathBuf, + } + let Ok(a) = serde_json::from_slice::(body) else { return error(400, "bad request") }; + if !p.settings && !within(&self.library_roots(), &a.path) { + return denied("open playlists outside the library folders"); + } + json(200, crate::playout::parse_m3u(&a.path)) + } + ("/api/v1/plays", false) => { + use crate::auth::tool; + if !(p.tool(tool::REPORTS) || p.tool(tool::HISTORY)) { + return denied("see the play history"); + } + json(200, serde_json::json!({ "csv": std::fs::read_to_string(data_dir().join("history.csv")).unwrap_or_default() })) } _ => error(404, "unknown call"), } } + fn users(&self) -> Vec { + self.accounts + .lock() + .users + .iter() + .map(|u| UserWire { name: u.name.clone(), admin: u.admin, perms: u.perms, disabled: u.disabled }) + .collect() + } + + fn user_op(&self, op: UserOp, me: &Operator) -> Result<(), String> { + let mut a = self.accounts.lock(); + let what = match op { + UserOp::Add { name, password, admin, perms } => { + a.add_with(&name, &password, admin, perms)?; + format!("added {}", name.trim()) + } + UserOp::Update { name, admin, perms, disabled } => { + if name == me.name && (disabled || !admin) && me.admin { + return Err("You can't take away your own administrator access or disable yourself".into()); + } + a.update(&name, admin, perms, disabled)?; + format!("changed {name}") + } + UserOp::Password { name, password } => { + a.set_password(&name, &password)?; + format!("set a new password for {name}") + } + UserOp::Delete { name } => { + if name == me.name { + return Err("You can't delete your own account".into()); + } + a.delete(&name)?; + format!("deleted {name}") + } + }; + drop(a); + self.core.lock().say(format!("Server: {} {what}", me.name)); + Ok(()) + } + + /// Download and install the newer release, then start again (in the background). + fn self_update(self: &Arc, me: &Operator) -> Reply { + let asset = { + let mut u = self.update.lock(); + if u.busy { + return error(409, "an update is already running"); + } + let Some(r) = u.latest.clone().filter(|r| crate::selfupdate::is_newer(&r.tag_name, crate::selfupdate::CURRENT)) else { + return error(400, "this server is up to date"); + }; + let Some(a) = r.server_asset().cloned() else { + return error(400, &format!("release {} has no RadioMaster Server package for this system", r.version())); + }; + u.busy = true; + u.state = "Downloading…".into(); + a + }; + self.core.lock().say(format!("Server: {} started the update to {}", me.name, asset.name)); + let api = self.clone(); + thread::spawn(move || { + let r = crate::selfupdate::install(&asset, &|s| api.update.lock().state = s); + match r { + Ok(()) => { + api.core.lock().say("Server: update installed, restarting"); + api.update.lock().state = "Installed: restarting…".into(); + (api.host.control)(Control::Restart); + } + Err(e) => { + api.core.lock().say(format!("Server: the update failed: {e}")); + let mut u = api.update.lock(); + u.busy = false; + u.state = format!("The update failed: {e}"); + } + } + }); + json(200, self.info(me)) + } + + /// A file from this computer, put in a folder on the server (never over a file that is there). + fn upload(&self, dir: &str, name: &str, body: &mut dyn Read, me: &Operator) -> Reply { + use std::path::Path; + let name = Path::new(name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + if dir.is_empty() || name.is_empty() || name.starts_with('.') { + return error(400, "a folder and a file name are needed"); + } + let dir = Path::new(dir); + if !dir.is_dir() { + return error(400, &format!("{} is not a folder on the server", dir.display())); + } + if !me.admin && !within(&self.library_roots(), dir) { + return error(403, &format!("your account ({}) may only upload into the library folders", me.name)); + } + let playlist = Path::new(&name).extension().is_some_and(|e| ["m3u", "m3u8"].contains(&e.to_string_lossy().to_lowercase().as_str())); + if !me.admin && !(crate::library::is_audio(Path::new(&name)) || playlist) { + return error(403, &format!("your account ({}) may only upload audio files and playlists", me.name)); + } + let max = if me.admin { UPLOAD_MAX } else { UPLOAD_MAX_STAFF }; + let mut target = dir.join(&name); + let (stem, ext) = ( + target.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(), + target.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default(), + ); + let mut n = 2; + while target.exists() { + target = dir.join(format!("{stem} ({n}){ext}")); + n += 1; + } + let written = std::fs::OpenOptions::new().write(true).create_new(true).open(&target).and_then(|mut f| { + let r = std::io::copy(&mut body.take(max + 1), &mut f) + .and_then(|n| (n <= max).then_some(()).ok_or_else(|| std::io::Error::other(format!("the file is over {} GB", max >> 30)))); + if r.is_err() { + drop(f); + let _ = std::fs::remove_file(&target); + } + r + }); + match written { + Ok(()) => { + self.core.lock().say(format!("Server: {} uploaded {}", me.name, target.display())); + json(200, serde_json::json!({ "ok": true, "path": target })) + } + Err(e) => error(500, &format!("could not write {}: {e}", target.display())), + } + } + + /// A file's bytes, for previews, waveforms and copies. Others than administrators only get files + /// in the library folders. + fn send_file(&self, req: tiny_http::Request, me: &Operator, path: &str) { + let path = std::path::PathBuf::from(path); + // (not "any file a playlist names": a presenter could name any file in one) + let ok = me.admin || within(&self.library_roots(), &path); + if !ok { + return respond(req, error(403, "only the server's administrators can fetch files outside its library folders"), false); + } + match std::fs::File::open(&path) { + Ok(f) if path.is_file() => { + let ct = header("Content-Type", "application/octet-stream"); + let _ = req.respond(tiny_http::Response::from_file(f).with_header(ct)); + } + _ => respond(req, error(404, "no such file on the server"), false), + } + } + + /// The library folders as the disk has them (links and `..` resolved), for keeping accounts + /// other than administrators inside them. + fn library_roots(&self) -> Vec { + let folders = self.core.lock().cfg.library_folders.clone(); + folders.iter().filter_map(|f| std::fs::canonicalize(f).ok()).collect() + } + /// A track's waveform for an app's track editor. The file is analysed here (the app doesn't have /// it), on a thread of its own: until that is done the answer is "pending" and the app asks again. fn wave(&self, body: &[u8]) -> Reply { @@ -855,16 +1202,27 @@ impl Api { )); } - fn info(&self) -> ServerInfo { + fn info(&self, me: &Operator) -> ServerInfo { let c = self.cfg.lock().clone(); + let (latest, can_update, update_state) = { + let u = self.update.lock(); + match u.latest.as_ref().filter(|r| crate::selfupdate::is_newer(&r.tag_name, crate::selfupdate::CURRENT)) { + Some(r) => (r.version(), r.server_asset().is_some(), u.state.clone()), + None => (String::new(), false, u.state.clone()), + } + }; let cert = self.certs.as_ref().map(|c| c.info()).unwrap_or_default(); - let latest = self.updates.lock().available.clone(); + let release_page = self.update.lock().latest.as_ref().map(|r| r.html_url.clone()).unwrap_or_default(); let acme = self.acme.lock().clone(); let tunnel = self.tunnel_status.lock().clone(); ServerInfo { name: c.name, - user: c.user, + user: me.name.clone(), + admin: me.admin, version: env!("CARGO_PKG_VERSION").into(), + latest, + can_update, + update_state, port: c.port, autoplay: c.autoplay, audio: c.audio, @@ -874,8 +1232,7 @@ impl Api { portal_port: c.portal_port, restart_needed: (c.https, c.portal, c.portal_port, c.acme && c.https) != (self.running.0, self.running.1, self.running.2, self.running.3 && self.running.0), - latest: latest.as_ref().map(|a| a.version.clone()).unwrap_or_default(), - release_page: latest.map(|a| a.page).unwrap_or_default(), + release_page, cert_expires: cert.not_after.unwrap_or(0), acme: c.acme, acme_domains: c.acme_domains, @@ -895,15 +1252,15 @@ impl Api { } } - fn update(self: &Arc, u: ServerUpdate) -> Reply { + fn update(self: &Arc, u: ServerUpdate, me: &Operator) -> Reply { let mut c = self.cfg.lock().clone(); if let Some((current, new)) = &u.password { - if !crate::auth::verify(&c.password_hash, current) { + // your own password + if !crate::auth::verify(&me.password_hash, current) { return error(403, "the current password is wrong"); } - match crate::auth::hash(new) { - Ok(h) => c.password_hash = h, - Err(e) => return error(400, &e), + if let Err(e) = self.accounts.lock().set_password(&me.name, new) { + return error(400, &e); } } if !u.name.trim().is_empty() { @@ -962,10 +1319,29 @@ impl Api { thread::sleep(Duration::from_millis(700)); } self.core.lock().say("Server: settings changed"); - json(200, self.info()) + json(200, self.info(me)) } } +/// The biggest file an administrator may upload… +const UPLOAD_MAX: u64 = 4 * 1024 * 1024 * 1024; +/// …and anyone else. +const UPLOAD_MAX_STAFF: u64 = 1024 * 1024 * 1024; + +/// `path` is in one of `roots` (canonical folders) once links and `..` are resolved; a path that +/// isn't there is in none. +fn within(roots: &[std::path::PathBuf], path: &std::path::Path) -> bool { + std::fs::canonicalize(path).is_ok_and(|p| roots.iter().any(|r| p.starts_with(r))) +} + +/// A value from a query string, URL-decoded. +fn query_str(query: &str, k: &str) -> Option { + query + .split('&') + .find_map(|kv| kv.split_once('=').filter(|(a, _)| *a == k)) + .map(|(_, v)| urlencoding::decode(&v.replace('+', " ")).map(|s| s.into_owned()).unwrap_or_default()) +} + fn header(name: &str, value: &str) -> tiny_http::Header { tiny_http::Header::from_bytes(name.as_bytes(), value.as_bytes()).unwrap() } @@ -1104,7 +1480,8 @@ mod tests { port: 0, ..Default::default() }; - let host = Host { reapply_audio: Box::new(|| {}), audio_report: Default::default(), forced_silent: true }; + let host = + Host { control: Box::new(|_| {}), reapply_audio: Box::new(|| {}), audio_report: Default::default(), forced_silent: true }; let addr = spawn(station.clone(), cfg, host).unwrap().addr; let s = crate::link::login(&format!("127.0.0.1:{}", addr.port()), "dj", "a long enough password", "").unwrap(); let token = Arc::new(Mutex::new(s.token.clone())); @@ -1177,7 +1554,8 @@ mod tests { port: 0, ..Default::default() }; - let host = Host { reapply_audio: Box::new(|| {}), audio_report: Default::default(), forced_silent: true }; + let host = + Host { control: Box::new(|_| {}), reapply_audio: Box::new(|| {}), audio_report: Default::default(), forced_silent: true }; let addr = spawn(station.clone(), cfg, host).unwrap().addr; let address = format!("127.0.0.1:{}", addr.port()); @@ -1271,7 +1649,8 @@ mod tests { port: 0, ..Default::default() }; - let host = Host { reapply_audio: Box::new(|| {}), audio_report: Default::default(), forced_silent: true }; + let host = + Host { control: Box::new(|_| {}), reapply_audio: Box::new(|| {}), audio_report: Default::default(), forced_silent: true }; let addr = spawn(station.clone(), cfg, host).unwrap().addr; let session = crate::link::login(&format!("127.0.0.1:{}", addr.port()), "studio", "correct horse battery", "").unwrap(); let local = Playout::new(Engine::new_detached(), crate::config::Config::default()); @@ -1308,6 +1687,164 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + fn start_plain(station: Arc>, user: &str, password: &str) -> (String, Arc) { + crate::config::set_data_dir(std::env::temp_dir().join(format!("rm-server-test-{}", std::process::id()))); + let cfg = ServerCfg { + user: user.into(), + password_hash: crate::auth::hash(password).unwrap(), + bind: "127.0.0.1".into(), + port: 0, + ..Default::default() + }; + // what the server program was asked to do: 1 stop, 2 restart + let asked = Arc::new(std::sync::atomic::AtomicU8::new(0)); + let a = asked.clone(); + let host = Host { + control: Box::new(move |c| a.store(if matches!(c, Control::Stop) { 1 } else { 2 }, Ordering::Relaxed)), + reapply_audio: Box::new(|| {}), + audio_report: Default::default(), + forced_silent: true, + }; + (format!("127.0.0.1:{}", spawn(station, cfg, host).unwrap().addr.port()), asked) + } + + /// An administrator adds a presenter on the server; the server holds the presenter to their + /// permissions, and only administrators manage accounts and the server itself. + #[test] + fn server_accounts_and_permissions() { + use crate::playout::sync::UserOp; + let station = Arc::new(Mutex::new(Playout::new(Engine::new_detached(), crate::config::Config::default()))); + station.lock().append_to_tab(0, vec![row("one")]); + let (address, asked) = start_plain(station.clone(), "chief", "the chief's password"); + let admin = crate::link::login(&address, "chief", "the chief's password", "").unwrap(); + assert!(admin.admin); + let presenter = crate::auth::Perms::presenter(); + let users = crate::link::Link::user_op( + &admin, + &UserOp::Add { name: "night dj".into(), password: "a presenter password".into(), admin: false, perms: presenter }, + ) + .unwrap(); + assert!(users.iter().any(|u| u.name == "night dj" && !u.admin)); + let dj = crate::link::login(&address, "night dj", "a presenter password", "").unwrap(); + assert!(!dj.admin && !dj.perms.settings && dj.perms.edit_playlists); + + // the presenter can't manage the server or its accounts + assert!(crate::link::Link::users(&dj).unwrap_err().contains("may not")); + assert!(crate::link::Link::server_control(&dj, "stop").is_err()); + assert_eq!(asked.load(Ordering::Relaxed), 0); + // …but can run the playlists, through a mirror like the app's + let local = Playout::new(Engine::new_detached(), crate::config::Config::default()); + let (tx, rx) = crossbeam_channel::unbounded(); + let mirror = Arc::new(Mutex::new(Playout::mirror_of(&local, tx))); + let link = crate::link::Link::start(mirror.clone(), dj.clone(), rx, Box::new(|| {})); + // (other tests may have left playlists in the shared data folder) + let n = station.lock().playlist.len(); + wait_for("the first copy", || mirror.lock().playlist.len() == n); + mirror.lock().append_to_tab(0, vec![row("two")]); + wait_for("the presenter's edit on the server", || station.lock().playlist.len() == n + 1); + // a setting the presenter may not change is refused, and the server's value comes back + mirror.lock().cfg.crossfade_secs = 11.0; + wait_for("the refusal", || mirror.lock().notify.lock().contains("may not change these settings")); + wait_for("the server's value again", || mirror.lock().cfg.crossfade_secs != 11.0); + assert_ne!(station.lock().cfg.crossfade_secs, 11.0); + link.stop(); + // (stopping the link signs that session out, in the background) + let dj = crate::link::login(&address, "night dj", "a presenter password", "").unwrap(); + // starting an encoder is refused; Next goes through + let r: serde_json::Value = ureq::post(&format!("{}/api/v1/cmd", dj.base)) + .set("Authorization", &format!("Bearer {}", dj.token)) + .send_json(serde_json::json!([{ "cmd": "encoder_start", "i": 0 }, { "cmd": "next" }])) + .unwrap() + .into_json() + .unwrap(); + assert_eq!(r["refused"], 1); + + // disabled: signed out at once, and can't sign in again + crate::link::Link::user_op(&admin, &UserOp::Update { name: "night dj".into(), admin: false, perms: presenter, disabled: true }) + .unwrap(); + assert!(crate::link::Link::server_info(&dj).is_err()); + assert!(crate::link::login(&address, "night dj", "a presenter password", "").is_err()); + // administrators can't lock themselves out + assert!(crate::link::Link::user_op(&admin, &UserOp::Delete { name: "chief".into() }).is_err()); + // restart goes to the server program + let info = crate::link::Link::server_control(&admin, "restart").unwrap(); + assert!(info.admin && info.update_state.contains("Restarting")); + wait_for("the restart request", || asked.load(Ordering::Relaxed) == 2); + } + + /// The app browses the server's folders, uploads into them and fetches a file back. + #[test] + fn server_files() { + let station = Arc::new(Mutex::new(Playout::new(Engine::new_detached(), crate::config::Config::default()))); + let music = std::env::temp_dir().join(format!("rm-server-files-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&music); + std::fs::create_dir_all(music.join("Jingles")).unwrap(); + std::fs::write(music.join("a song.mp3"), b"not really audio").unwrap(); + std::fs::write(music.join("notes.txt"), b"hello").unwrap(); + std::fs::write(music.join("Jingles").join("id.mp3"), b"jingle").unwrap(); + station.lock().cfg.library_folders = vec![music.to_string_lossy().to_string()]; + let (address, _) = start_plain(station.clone(), "files admin", "the files password"); + let s = crate::link::login(&address, "files admin", "the files password", "").unwrap(); + let fs = crate::link::Fs::Server(s.clone()); + + // browsing starts in the first library folder + let list = fs.list(std::path::Path::new(""), false, &[]); + assert_eq!(list.path, music); + let names: Vec = list.entries.iter().map(|e| e.path.file_name().unwrap().to_string_lossy().to_string()).collect(); + assert_eq!(names, vec!["Jingles", "a song.mp3"], "folders first, audio only"); + assert!(list.places.contains(&music)); + assert_eq!(fs.list(&music, true, &[]).entries.len(), 3, "every file"); + assert_eq!(fs.audio_files_in(&music).len(), 2); + + // an upload lands in the folder, never over a file that's there + let local = std::env::temp_dir().join(format!("rm-upload-{}.mp3", std::process::id())); + std::fs::write(&local, b"from this computer").unwrap(); + let up = fs.upload(&local, &music.join("Jingles")).unwrap(); + assert_eq!(std::fs::read(&up).unwrap(), b"from this computer"); + let again = fs.upload(&local, &music.join("Jingles")).unwrap(); + assert_ne!(up, again); + assert!(fs.upload(&local, &music.join("no such folder")).is_err()); + + // a file comes back as it is + let url = format!("{}/api/v1/fs/file?path={}", s.base, urlencoding::encode(&music.join("a song.mp3").to_string_lossy())); + let r = ureq::get(&url).set("Authorization", &format!("Bearer {}", s.token)).call().unwrap(); + assert_eq!(r.into_string().unwrap(), "not really audio"); + assert!(ureq::get(&url).call().is_err(), "only when signed in"); + assert!(crate::link::Link::plays(&s).is_ok()); + + // a presenter stays inside the library folders: no `..`, no other folders + let presenter = crate::auth::Perms::presenter(); + let op = crate::playout::sync::UserOp::Add { + name: "files dj".into(), + password: "a files dj password".into(), + admin: false, + perms: presenter, + }; + crate::link::Link::user_op(&s, &op).unwrap(); + let dj = crate::link::login(&address, "files dj", "a files dj password", "").unwrap(); + let dfs = crate::link::Fs::Server(dj.clone()); + assert_eq!(dfs.list(&music, false, &[]).entries.len(), 2); + assert!(dfs.list(&music, false, &[]).places.iter().all(|p| p.starts_with(&music))); + assert!(!dfs.list(&std::env::temp_dir(), true, &[]).error.is_empty(), "outside the library"); + let fetch = |p: &std::path::Path| { + let url = format!("{}/api/v1/fs/file?path={}", dj.base, urlencoding::encode(&p.to_string_lossy())); + ureq::get(&url).set("Authorization", &format!("Bearer {}", dj.token)).call().map(|r| r.into_string().unwrap()) + }; + assert_eq!(fetch(&music.join("Jingles").join("id.mp3")).unwrap(), "jingle"); + let outside = std::env::temp_dir().join(format!("rm-outside-{}.mp3", std::process::id())); + std::fs::write(&outside, b"private").unwrap(); + let sneaky = music.join("..").join(outside.file_name().unwrap()); + assert!(fetch(&sneaky).is_err(), "`..` doesn't leave the library"); + assert!(fetch(&outside).is_err()); + assert!(dfs.upload(&local, &music.join("Jingles")).is_ok()); + assert!(dfs.upload(&local, &music.join("..")).is_err(), "uploads stay in the library"); + assert!(dfs.upload(&local, &std::env::temp_dir()).is_err()); + assert!(dfs.upload(&music.join("notes.txt"), &music).unwrap_err().contains("audio files")); + let _ = std::fs::remove_file(&outside); + let _ = std::fs::remove_dir_all(&music); + let _ = std::fs::remove_file(&local); + } + /// HTTPS with the server's own certificate: the app has to trust it once, finds the https:// /// address by itself, and refuses a different certificate later. The web portal is served too. #[test] @@ -1327,7 +1864,8 @@ mod tests { portal_port: 0, ..Default::default() }; - let host = || Host { reapply_audio: Box::new(|| {}), audio_report: Default::default(), forced_silent: true }; + let host = + || Host { control: Box::new(|_| {}), reapply_audio: Box::new(|| {}), audio_report: Default::default(), forced_silent: true }; let l = spawn(station.clone(), cfg.clone(), host()).unwrap(); assert!(l.self_signed); let fp = l.fingerprint.clone().unwrap(); @@ -1439,7 +1977,8 @@ mod tests { https: true, ..Default::default() }; - let host = Host { reapply_audio: Box::new(|| {}), audio_report: Default::default(), forced_silent: true }; + let host = + Host { control: Box::new(|_| {}), reapply_audio: Box::new(|| {}), audio_report: Default::default(), forced_silent: true }; let tls = crate::tls::server_tls("", "", &dir, true).unwrap(); assert!(tls.info().self_signed, "self-signed until Let's Encrypt has answered"); let l = spawn(station, cfg, host).unwrap(); @@ -1477,7 +2016,8 @@ mod tests { https: true, ..Default::default() }; - let host = Host { reapply_audio: Box::new(|| {}), audio_report: Default::default(), forced_silent: true }; + let host = + Host { control: Box::new(|_| {}), reapply_audio: Box::new(|| {}), audio_report: Default::default(), forced_silent: true }; let l = spawn(station, cfg, host).unwrap(); let s = crate::link::login(&format!("127.0.0.1:{}", l.addr.port()), "dj", "a long enough password", &l.fingerprint.unwrap()).unwrap(); diff --git a/src/server_main.rs b/src/server_main.rs index 66ef84f..dc945f7 100644 --- a/src/server_main.rs +++ b/src/server_main.rs @@ -22,12 +22,14 @@ mod playout; mod plugins; mod remote; mod scheduler; +mod selfupdate; mod server; mod stream; mod tls; mod tunnel; mod updater; +use auth::Accounts; use parking_lot::Mutex; use server::ServerCfg; use std::{ @@ -42,8 +44,9 @@ const HELP: &str = "RadioMaster Server — radio automation without a window Usage: RadioMaster-Server [options] - --setup set the station name, username, password and port, then exit - --user NAME username for --setup (asked for when left out) + --setup set the station name, an administrator's username and password, and the port, then exit + --user NAME administrator's username for --setup (asked for when left out); more accounts + are added in the app (File → Server users) by an administrator --password PASSWORD password for --setup (or set RADIOMASTER_PASSWORD; asked for when left out) --name NAME station name shown in the app (with --setup) --port N port to listen on (default 9259; saved with --setup) @@ -66,15 +69,22 @@ Usage: RadioMaster-Server [options] --portal-port N port for the web portal (default 9260) --data FOLDER data folder (default: the RadioMaster-Server folder in your settings folder) --no-audio don't use a sound card: stream only + --no-update-check don't look for newer releases on GitHub (saved with --setup) --list-devices list the sound cards and exit --version print the version and exit Desktop apps connect with File → Connect to Server, using this machine's address and port. -Options given without --setup apply to this run only."; +Options given without --setup apply to this run only. + +Administrators can update, restart and stop the server from the app. Under systemd it is started again +by the service (Restart=on-failure); under another service manager, set RADIOMASTER_SUPERVISED=1 so +it leaves restarting to that manager instead of starting a new copy itself."; /// Work for the main thread (which owns the sound cards). enum Ctl { Quit, + /// stop and start again (an administrator asked, or an update was installed) + Restart, /// the sound card settings changed Audio, } @@ -88,6 +98,7 @@ struct Args { bind: Option, data: Option, no_audio: bool, + no_update_check: bool, list_devices: bool, https: Option, cert: Option, @@ -114,6 +125,7 @@ fn parse_args() -> Result { bind: None, data: None, no_audio: false, + no_update_check: false, list_devices: false, https: None, cert: None, @@ -141,6 +153,7 @@ fn parse_args() -> Result { "--bind" => a.bind = Some(value("an address")?), "--data" => a.data = Some(PathBuf::from(value("a folder")?)), "--no-audio" => a.no_audio = true, + "--no-update-check" => a.no_update_check = true, "--https" => a.https = Some(true), "--no-https" => a.https = Some(false), "--cert" => a.cert = Some(value("a file")?), @@ -200,9 +213,11 @@ fn ask(prompt: &str, default: &str) -> String { } } -/// Station name, username, password and port: from the options, else asked for at the terminal. -fn setup(args: &Args, old: Option) -> Result { +/// Station name, an administrator's username and password, and the port: from the options, else +/// asked for at the terminal. +fn setup(args: &Args, old: Option, accounts: &mut Accounts) -> Result { let mut cfg = old.unwrap_or_default(); + cfg.move_user(accounts); let tty = std::io::stdin().is_terminal(); if tty { println!("RadioMaster Server setup — desktop apps sign in with this username and password."); @@ -212,15 +227,16 @@ fn setup(args: &Args, old: Option) -> Result { None if tty => ask("Station name", &cfg.name), None => cfg.name, }; - cfg.user = match &args.user { + let admin = accounts.users.iter().find(|u| u.admin && !u.disabled).map(|u| u.name.clone()).unwrap_or_default(); + let user = match &args.user { Some(u) => u.clone(), - None if tty => ask("Username", &cfg.user), - None => cfg.user, + None if tty => ask("Administrator username", &admin), + None => admin, }; - if cfg.user.trim().is_empty() { - return Err("a username is needed (--user NAME)".into()); + let user = user.trim().to_string(); + if user.chars().count() < 2 { + return Err("a username of at least 2 characters is needed (--user NAME)".into()); } - cfg.user = cfg.user.trim().to_string(); let password = match &args.password { Some(p) => p.clone(), None if tty => loop { @@ -236,7 +252,15 @@ fn setup(args: &Args, old: Option) -> Result { }, None => return Err("a password is needed (--password, or set RADIOMASTER_PASSWORD)".into()), }; - cfg.password_hash = auth::hash(&password)?; + let password_hash = auth::hash(&password)?; + match accounts.users.iter_mut().find(|u| u.name == user) { + Some(u) => { + u.password_hash = password_hash; + u.admin = true; + u.disabled = false; + } + None => accounts.users.push(auth::Operator { name: user, password_hash, admin: true, perms: Default::default(), disabled: false }), + } cfg.port = match args.port { Some(p) => p, None if tty => ask("Port", &cfg.port.to_string()).parse().map_err(|_| "the port must be a number from 1 to 65535".to_string())?, @@ -270,6 +294,9 @@ fn setup(args: &Args, old: Option) -> Result { .map_err(|_| "the port must be a number from 1 to 65535".to_string())?, None => cfg.portal_port, }; + if args.no_update_check { + cfg.check_updates = false; + } apply_tls_args(args, &mut cfg); Ok(cfg) } @@ -346,10 +373,11 @@ fn main() { let saved = ServerCfg::load(); let mut scfg = if args.setup || !saved.as_ref().is_some_and(|s| s.ready()) { - match setup(&args, saved) { + let mut accounts = Accounts::load(); + match setup(&args, saved, &mut accounts) { Ok(c) => { - if let Err(e) = c.save() { - eprintln!("Could not save {}: {e}", ServerCfg::path().display()); + if let Err(e) = accounts.save().map_err(std::io::Error::other).and_then(|_| c.save()) { + eprintln!("Could not save the settings in {}: {e}", config::data_dir().display()); std::process::exit(1); } println!("Saved {}", ServerCfg::path().display()); @@ -364,8 +392,20 @@ fn main() { } } } else { - saved.unwrap_or_default() + let mut c = saved.unwrap_or_default(); + // a server set up before v0.2.9: its one account becomes the first administrator + let mut accounts = Accounts::load(); + if c.move_user(&mut accounts) { + match accounts.save().map_err(std::io::Error::other).and_then(|_| c.save()) { + Ok(()) => println!("Moved the server's account to {}", config::data_dir().join("operators.json").display()), + Err(e) => eprintln!("Could not move the server's account to operators.json: {e}"), + } + } + c }; + if args.no_update_check { + scfg.check_updates = false; + } if let Some(p) = args.port { scfg.port = p; } @@ -427,9 +467,16 @@ fn main() { }); } let (ctl_tx, ctl_rx) = std::sync::mpsc::channel::(); - let host = { + let host = || { let tx = Mutex::new(ctl_tx.clone()); + let control = Mutex::new(ctl_tx.clone()); server::Host { + control: Box::new(move |c| { + let _ = control.lock().send(match c { + server::Control::Stop => Ctl::Quit, + server::Control::Restart => Ctl::Restart, + }); + }), reapply_audio: Box::new(move || { let _ = tx.lock().send(Ctl::Audio); }), @@ -437,10 +484,19 @@ fn main() { forced_silent: args.no_audio, } }; - match server::spawn(core.clone(), scfg.clone(), host) { + // a copy started by a restart waits for the old one to let go of the ports + let restarted = std::env::var_os(selfupdate::RESTARTED_ENV).is_some(); + let started = std::time::Instant::now(); + let listening = loop { + match server::spawn(core.clone(), scfg.clone(), host()) { + Err(_) if restarted && started.elapsed() < Duration::from_secs(30) => thread::sleep(Duration::from_millis(500)), + r => break r, + } + }; + match listening { Ok(l) => { let scheme = if l.fingerprint.is_some() { "https" } else { "http" }; - println!("Listening on {scheme}://{} — sign in as '{}' from File → Connect to Server", l.addr, scfg.user); + println!("Listening on {scheme}://{} — sign in from File → Connect to Server", l.addr); if let Some(fp) = &l.fingerprint { if scfg.acme_order().is_some() && l.self_signed { println!("HTTPS: getting a Let's Encrypt certificate for {} (self-signed until then)", scfg.acme_domains); @@ -478,9 +534,14 @@ fn main() { } // the station log goes to the console (and to journald / the service log) let mut printed = core.lock().log_total; + let mut restart = false; loop { let quit = match ctl_rx.recv_timeout(Duration::from_millis(500)) { Ok(Ctl::Quit) => true, + Ok(Ctl::Restart) => { + restart = true; + true + } Ok(Ctl::Audio) => { open_audio(&mut audio, ServerCfg::load().is_none_or(|c| c.audio)); false @@ -513,4 +574,14 @@ fn main() { c.plugins.stop_services(); drop(c); playout::flush_writes(); + if restart { + if selfupdate::supervised() { + println!("Restarting (the service manager starts it again)…"); + std::process::exit(selfupdate::RESTART_EXIT); + } + match selfupdate::start_again() { + Ok(()) => println!("Restarting…"), + Err(e) => eprintln!("Could not start again: {e}"), + } + } } diff --git a/src/serveradmin.rs b/src/serveradmin.rs new file mode 100644 index 0000000..7775c88 --- /dev/null +++ b/src/serveradmin.rs @@ -0,0 +1,288 @@ +//! Running a RadioMaster Server from the app, for its administrators: its accounts (File → Server +//! users), and its program (an update notice like the app's own, Update, Restart and Stop). +use super::*; +use crate::link::Link; +use crate::playout::sync::{ServerInfo, UserOp, UserWire}; + +type Answer = Option>>; + +/// How often the app asks the server about itself (its version, a newer release). +const INFO_EVERY: Duration = Duration::from_secs(10 * 60); + +#[derive(Default)] +pub(super) struct ServerAdmin { + /// what the server said about itself last + info: Option, + info_rx: Answer, + info_next: Option, + pub(super) show_update: bool, + /// the update notice opened once for this connection + told: bool, + control_rx: Answer, + /// Stop or Restart pressed: waiting for "Stop!/Keep" + confirm: Option<&'static str>, + control_msg: String, + show_users: bool, + users: Vec, + users_rx: Answer>, + /// (ok, message) + users_msg: (bool, String), +} + +impl ServerAdmin { + pub(super) fn window_open(&self) -> bool { + self.show_update || self.show_users + } +} + +/// Run `f` off the UI thread and wake the UI with its answer. +fn ask(ctx: &egui::Context, f: impl FnOnce() -> Result + Send + 'static) -> Answer { + let (tx, rx) = mpsc::channel(); + let ctx = ctx.clone(); + thread::spawn(move || { + let _ = tx.send(f()); + ctx.request_repaint(); + }); + Some(rx) +} + +/// The newest version the server could run: a release it found, or this app's own version when the +/// server is older than the app. None: it is up to date. +fn behind(info: &ServerInfo) -> Option { + let app = crate::updater::CURRENT; + if !info.latest.is_empty() && crate::updater::is_newer(&info.latest, app) { + Some(info.latest.clone()) + } else if crate::updater::is_newer(app, &info.version) { + Some(app.to_string()) + } else if !info.latest.is_empty() { + // (the server only sends `latest` when it is newer than the server itself) + Some(info.latest.clone()) + } else { + None + } +} + +impl App { + /// The account signed in on the server is an administrator. + pub(super) fn server_admin(&self) -> bool { + self.srv.session().is_some_and(|s| s.admin) + } + + pub(super) fn open_server_users(&mut self) { + let Some(s) = self.srv.session() else { return }; + let a = &mut self.srv.admin; + a.show_users = true; + a.users_msg = (true, String::new()); + a.users_rx = ask(&self.ctx, move || Link::users(&s)); + } + + pub(super) fn server_admin_windows(&mut self, ctx: &egui::Context) { + let Some(s) = self.srv.session() else { return }; + let a = &mut self.srv.admin; + // what the server says about itself: now and then, and every couple of seconds while it updates + if let Some(Ok(r)) = a.info_rx.as_ref().map(|rx| rx.try_recv()) { + a.info_rx = None; + if let Ok(info) = r { + if !a.told && s.admin && behind(&info).is_some() && info.update_state.is_empty() { + a.told = true; + a.show_update = true; + } + a.info = Some(info); + } + } + let busy = a.info.as_ref().is_some_and(|i| !i.update_state.is_empty() && !i.update_state.starts_with("The update failed")); + if a.info_rx.is_none() && a.info_next.is_none_or(|t| std::time::Instant::now() >= t) { + a.info_next = Some(std::time::Instant::now() + if busy { Duration::from_secs(2) } else { INFO_EVERY }); + let s = s.clone(); + a.info_rx = ask(ctx, move || Link::server_info(&s)); + } + if let Some(Ok(r)) = a.control_rx.as_ref().map(|rx| rx.try_recv()) { + a.control_rx = None; + match r { + Ok(info) => { + a.control_msg = info.update_state.clone(); + a.info = Some(info); + a.info_next = Some(std::time::Instant::now() + Duration::from_secs(2)); + } + Err(e) => a.control_msg = e, + } + } + self.server_update_window(ctx, &s); + self.server_users_window(ctx, &s); + } + + /// "⬆ Server update" beside the connection chip, like the app's own update button. + pub(super) fn server_update_button(&mut self, ui: &mut egui::Ui) { + let Some(v) = self.srv.admin.info.as_ref().and_then(behind) else { return }; + let b = egui::Button::new(RichText::new(format!("⬆ Server: v{v}")).strong().color(Color32::WHITE)) + .fill(Color32::from_rgb(20, 150, 235)); + let hover = if self.server_admin() { + tr("RadioMaster Server is behind — click to update it") + } else { + tr("RadioMaster Server is behind: ask one of its administrators to update it") + }; + if ui.add(b).on_hover_text(hover).clicked() { + self.srv.admin.show_update = true; + } + } + + /// Update, Restart and Stop, in the update window and Server settings (administrators only). + pub(super) fn server_program_section(&mut self, ui: &mut egui::Ui) { + let Some(s) = self.srv.session() else { return }; + let a = &mut self.srv.admin; + let info = a.info.clone().unwrap_or_default(); + ui.label(format!( + "RadioMaster Server {} · this app {}", + if info.version.is_empty() { "?" } else { &info.version }, + crate::updater::CURRENT + )); + match behind(&info) { + Some(v) => ui.label(RichText::new(format!("Version {v} is available.")).strong()), + None if a.info.is_some() => ui.colored_label(GREEN, tr("✔ The server is up to date.")), + None => ui.label(RichText::new(tr("Asking the server…")).color(GRAY)), + }; + if !s.admin { + ui.label(RichText::new(tr("Only the server's administrators can update, restart or stop it.")).small().color(GRAY)); + return; + } + let working = a.control_rx.is_some() || (!info.update_state.is_empty() && !info.update_state.starts_with("The update failed")); + let mut go: Option<&'static str> = None; + ui.horizontal_wrapped(|ui| { + let can = behind(&info).is_some() && info.can_update && !working; + let r = ui.add_enabled(can, egui::Button::new(RichText::new(tr("⬇ Update the server")).strong())); + if r.clicked() { + go = Some("update"); + } + if behind(&info).is_some() && !info.can_update && !info.latest.is_empty() { + r.on_disabled_hover_text(tr("There's no RadioMaster Server download for the server's system: update it by hand")); + } + for (what, label) in [("restart", tr("Restart")), ("stop", tr("Stop"))] { + if a.confirm == Some(what) { + if ui.button(RichText::new(format!("{label}!")).color(RED)).clicked() { + go = Some(what); + a.confirm = None; + } + if ui.button(tr("Keep")).clicked() { + a.confirm = None; + } + } else if ui.add_enabled(!working, egui::Button::new(label)).clicked() { + a.confirm = Some(what); + } + } + if working { + ui.spinner(); + } + }); + if a.confirm == Some("stop") { + ui.colored_label(AMBER, tr("A stopped server is off air until someone starts it again on that computer.")); + } + let state = if a.control_msg.is_empty() { info.update_state.clone() } else { a.control_msg.clone() }; + if !state.is_empty() { + ui.label(RichText::new(state).small()); + } + ui.label( + RichText::new(tr("The station goes quiet while the server restarts (a few seconds) — best done between shows. This app reconnects by itself.")) + .small() + .color(GRAY), + ); + if let Some(what) = go { + a.control_msg.clear(); + a.control_rx = ask(&self.ctx, move || Link::server_control(&s, what)); + } + } + + fn server_update_window(&mut self, ctx: &egui::Context, s: &crate::link::Session) { + if !self.srv.admin.show_update { + return; + } + let mut open = true; + let latest = self.srv.admin.info.as_ref().and_then(behind); + Self::window_for(ctx, "Update RadioMaster Server", [480.0, 300.0]) + .id(egui::Id::new("server_update")) + .collapsible(false) + .open(&mut open) + .show(ctx, |ui| { + ui.label(RichText::new(&s.name).strong()); + self.server_program_section(ui); + if let Some(v) = &latest { + if ui.link(tr("What's new (release page)")).clicked() { + ui.ctx().open_url(egui::OpenUrl::new_tab(format!("https://github.com/{}/releases/tag/v{v}", crate::updater::REPO))); + } + } + }); + self.srv.admin.show_update = open; + } + + fn server_users_window(&mut self, ctx: &egui::Context, s: &crate::link::Session) { + if !self.srv.admin.show_users { + return; + } + if let Some(Ok(r)) = self.srv.admin.users_rx.as_ref().map(|rx| rx.try_recv()) { + self.srv.admin.users_rx = None; + match r { + Ok(u) => { + self.srv.admin.users = u; + if self.srv.admin.users_msg.1.is_empty() { + self.srv.admin.users_msg = (true, String::new()); + } + } + Err(e) => self.srv.admin.users_msg = (false, e), + } + } + let rows: Vec = self + .srv + .admin + .users + .iter() + .map(|u| UserRow { name: u.name.clone(), admin: u.admin, perms: u.perms, disabled: u.disabled }) + .collect(); + let busy = self.srv.admin.users_rx.is_some(); + let mut open = true; + let mut acts = vec![]; + let title = format!("{} — {}", tr("Server users"), s.name); + Self::window_for(ctx, &title, [1000.0, 520.0]).id(egui::Id::new("server_users")).open(&mut open).show(ctx, |ui| { + ui.label( + RichText::new(tr( + "These accounts sign in to the server (from the app and the web portal). The server checks every action against them.", + )) + .small() + .color(GRAY), + ); + ui.add_enabled_ui(!busy, |ui| acts = self.users_editor(ui, "server_users_grid", &rows, &s.user)); + ui.horizontal(|ui| { + if busy { + ui.spinner(); + } + let (ok, msg) = &self.srv.admin.users_msg; + if !msg.is_empty() { + ui.colored_label(if *ok { GREEN } else { RED }, msg); + } + }); + }); + self.srv.admin.show_users = open; + // one change at a time: the answer is the list as the server has it then + if let Some(act) = acts.into_iter().next() { + let (op, done) = match act { + UserAct::Add { name, password, admin, perms } => { + let msg = format!("Created {}", name.trim()); + (UserOp::Add { name, password, admin, perms }, msg) + } + UserAct::Change { name, admin, perms, disabled } => { + let msg = format!("Saved {name}"); + (UserOp::Update { name, admin, perms, disabled }, msg) + } + UserAct::Password { name, password } => { + let msg = format!("Saved a new password for {name}"); + (UserOp::Password { name, password }, msg) + } + UserAct::Delete { name } => { + let msg = format!("Deleted {name}"); + (UserOp::Delete { name }, msg) + } + }; + self.srv.admin.users_msg = (true, done); + let s = s.clone(); + self.srv.admin.users_rx = ask(ctx, move || Link::user_op(&s, &op)); + } + } +} diff --git a/src/serverui.rs b/src/serverui.rs index 9a54ee7..d82c23d 100644 --- a/src/serverui.rs +++ b/src/serverui.rs @@ -71,8 +71,8 @@ pub(super) struct ServerUi { pw_again: String, /// a new Cloudflare Tunnel token typed in Server settings (the saved one is never shown) tunnel_token: String, - /// "Add library folder" while connected: a folder on the server, typed in - lib_folder: Option, + /// accounts, updates, restart and stop (administrators) + pub(super) admin: super::serveradmin::ServerAdmin, conn: Option, } @@ -102,7 +102,7 @@ impl Default for ServerUi { pw_new: String::new(), pw_again: String::new(), tunnel_token: String::new(), - lib_folder: None, + admin: Default::default(), conn: None, } } @@ -113,6 +113,11 @@ impl ServerUi { self.conn.is_some() } + /// The sign-in as it stands (None when not connected). + pub(super) fn session(&self) -> Option { + self.conn.as_ref().map(|c| c.link.current()) + } + /// Connected with Connect to Remote Server on the login screen (no local account). pub(super) fn signed_in_remotely(&self) -> bool { self.remote_login @@ -120,7 +125,7 @@ impl ServerUi { /// A window of this tool is open (web views must hide under it). pub(super) fn window_open(&self) -> bool { - self.show_connect || self.ask.is_some() || self.show_status || self.show_settings || self.lib_folder.is_some() + self.show_connect || self.ask.is_some() || self.show_status || self.show_settings || self.admin.window_open() } } @@ -128,9 +133,15 @@ impl App { /// File menu entries. pub(super) fn server_menu(&mut self, ui: &mut egui::Ui, core: &mut Playout) { if self.srv.connected() { - if ui.button(tr("Server settings…")).clicked() { - self.open_server_settings(); - ui.close_menu(); + if self.server_admin() { + if ui.button(tr("Server settings…")).clicked() { + self.open_server_settings(); + ui.close_menu(); + } + if ui.button(tr("Server users…")).clicked() { + self.open_server_users(); + ui.close_menu(); + } } if ui.button(tr("Server info…")).clicked() { self.srv.show_status = true; @@ -179,13 +190,9 @@ impl App { self.auth_error.clear(); } - /// "Add library folder…": a folder picker here, or while connected a folder on the server, typed in. + /// "Add library folder…": a folder here, or while connected a folder on the server. pub(super) fn pick_library_folder(&mut self) { - if self.srv.connected() { - self.srv.lib_folder = Some(String::new()); - } else { - self.dlg(|| rfd::FileDialog::new().pick_folder().map(Dlg::LibFolder)); - } + self.pick(Pick::Folder, tr("Add library folder"), |_, v| Some(Dlg::LibFolder(v.into_iter().next()?))); } /// The engine the sound cards belong to: the local one, also while a server is mirrored. @@ -240,8 +247,9 @@ impl App { self.connect_window(ctx); self.save_session_window(ctx, core); self.server_status_window(ctx, core); - self.server_folder_window(ctx, core); self.server_settings_window(ctx); + self.server_picker_window(ctx); + self.server_admin_windows(ctx); } pub(super) fn connect_window(&mut self, ctx: &egui::Context) { @@ -503,6 +511,13 @@ impl App { } fn reset_playlist_view(&mut self) { + // file browsers start again on this computer's or the server's folders + self.files.reset(); + self.srv.admin = Default::default(); + self.rep.forget(); + self.fx_dir.clear(); + self.fx_list.clear(); + self.fx_loading.clear(); self.view_pl = usize::MAX; self.sel.clear(); self.anchor = None; @@ -547,7 +562,11 @@ impl App { } ui.end_row(); ui.label(tr("Signed in as")); - ui.label(&s.user); + if s.admin { + ui.label(format!("{} ({})", s.user, tr("administrator"))); + } else { + ui.label(&s.user).on_hover_text(tr("What this account may do is set by the server's administrators")); + } ui.end_row(); ui.label(tr("Version")); ui.label(&s.version); @@ -572,14 +591,17 @@ impl App { }); ui.add_space(6.0); ui.label( - RichText::new(tr("Files, folders and the library are the server's. Previews and waveforms need the files on this computer, so they don't work while connected. The Mic button sends this computer's microphone to the server, which puts it on air.")) + RichText::new(tr("Files, folders and the library are the server's: file pickers and the file explorer show the server's folders, and Upload copies files from this computer to it. The Mic button sends this computer's microphone to the server, which puts it on air.")) .small() .color(GRAY), ); + ui.separator(); + ui.label(RichText::new(tr("Server program")).strong()); + self.server_program_section(ui); }); ui.separator(); ui.horizontal(|ui| { - if ui.button(tr("Server settings…")).clicked() { + if self.server_admin() && ui.button(tr("Server settings…")).clicked() { self.open_server_settings(); } if ui.button(RichText::new(tr("Disconnect")).strong()).clicked() { @@ -597,33 +619,6 @@ impl App { self.disconnect(core); } } - - fn server_folder_window(&mut self, ctx: &egui::Context, core: &mut Playout) { - let Some(mut folder) = self.srv.lib_folder.take() else { return }; - let mut open = true; - let mut done = false; - Self::window_for(ctx, "Add a library folder on the server", [440.0, 200.0]).collapsible(false).open(&mut open).show(ctx, |ui| { - ui.label(tr("Type the folder as the server sees it, e.g. /srv/music or D:\\Music.")); - ui.add(egui::TextEdit::singleline(&mut folder).desired_width(380.0)); - ui.separator(); - ui.horizontal(|ui| { - if ui.add_enabled(!folder.trim().is_empty(), egui::Button::new(tr("Add and rescan"))).clicked() { - let f = folder.trim().to_string(); - if !core.cfg.library_folders.contains(&f) { - core.cfg.library_folders.push(f); - } - core.start_scan(); - done = true; - } - if ui.button(tr("Cancel")).clicked() { - done = true; - } - }); - }); - if open && !done { - self.srv.lib_folder = Some(folder); - } - } } impl App { @@ -689,6 +684,7 @@ impl App { let mut open = true; let mut save = false; let mut reload = false; + let mut users = false; Self::window_for(ctx, "Server settings", [500.0, 520.0]).collapsible(false).open(&mut open).show(ctx, |ui| { let srv = &mut self.srv; let Some(info) = srv.settings.as_mut() else { @@ -836,7 +832,16 @@ impl App { ui.colored_label(AMBER, tr("Saved: restart RadioMaster Server to use them.")); } ui.separator(); - ui.label(RichText::new(tr("Change the server password")).strong()); + ui.horizontal_wrapped(|ui| { + if ui.button(tr("Server users…")).clicked() { + users = true; + } + if ui.button(tr("Update, restart or stop…")).clicked() { + srv.admin.show_update = true; + } + }); + ui.separator(); + ui.label(RichText::new(tr("Change your password")).strong()); egui::Grid::new("srv_pw").num_columns(2).spacing([10.0, 6.0]).show(ui, |ui| { ui.label(tr("Current password")); ui.add(egui::TextEdit::singleline(&mut srv.pw_current).password(true).desired_width(240.0)); @@ -876,6 +881,9 @@ impl App { if reload { self.open_server_settings(); } + if users { + self.open_server_users(); + } if save { let Some(info) = &self.srv.settings else { return }; let password = if self.srv.pw_new.is_empty() && self.srv.pw_current.is_empty() { diff --git a/src/sync.rs b/src/sync.rs index 7a6d4e2..26d8113 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -99,6 +99,132 @@ pub enum Cmd { }, } +impl Cmd { + /// Whether an account with these permissions may send this action. + pub fn allowed(&self, p: &crate::auth::Perms) -> bool { + use crate::auth::tool; + match self { + Cmd::PlayPressed { .. } + | Cmd::PlayIndex { .. } + | Cmd::PauseToggle + | Cmd::Stop + | Cmd::Next + | Cmd::Prev + | Cmd::Volume { .. } + | Cmd::Seek { .. } + | Cmd::TabOnAir { .. } => p.playback, + Cmd::Cart { .. } | Cmd::StopCarts => p.jingles, + Cmd::EncoderStart { .. } | Cmd::EncoderStop { .. } => p.streaming, + Cmd::Command { .. } | Cmd::Insert { .. } | Cmd::RemoveRows { .. } => p.edit_playlists, + Cmd::Event { .. } => p.tool(tool::SCHEDULER), + Cmd::Generate { .. } => p.edit_playlists && p.tool(tool::GENERATOR), + Cmd::FireRule { .. } => p.edit_playlists && p.tool(tool::ADS), + Cmd::Rescan => p.settings || p.tool(tool::LIBRARY), + Cmd::WriteTags { .. } | Cmd::SetCues { .. } | Cmd::ClearCues { .. } | Cmd::ReanalyseCues => p.tool(tool::TRACK_EDITOR), + } + } +} + +/// The first part of the station settings that differs between `old` and `new` and that an account +/// with `p` may not change (None: it may make the whole change). +pub fn cfg_denied(old: &Config, new: &Config, p: &crate::auth::Perms) -> Option { + use crate::auth::tool; + let (Ok(serde_json::Value::Object(a)), Ok(serde_json::Value::Object(b))) = (serde_json::to_value(old), serde_json::to_value(new)) + else { + return Some("settings".into()); + }; + let allowed = |key: &str| match key { + "categories" | "templates" | "auto_enabled" | "auto_template" | "auto_min_mins" | "repeat" => p.tool(tool::GENERATOR), + "ads" | "ad_breaks" | "ad_rules" | "ad_rules_v" | "rotations" | "teaser_secs" => p.tool(tool::ADS), + "events" => p.tool(tool::SCHEDULER), + "dsp" | "eq_presets" => p.tool(tool::AUDIO_FX), + "azuracast" => p.tool(tool::AZURACAST), + "carts" => p.jingles, + "encoders" => p.streaming, + "mic_gain" | "mic_duck" | "mic_mode" | "volume" => p.playback, + "loop_playlist" | "random_fill" | "hour_lines" | "announce" => p.edit_playlists, + _ => p.settings, + }; + let null = serde_json::Value::Null; + a.keys().chain(b.keys()).find(|k| a.get(*k).unwrap_or(&null) != b.get(*k).unwrap_or(&null) && !allowed(k)).cloned() +} + +/// A server account as an administrator sees it (never with its password). +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct UserWire { + pub name: String, + pub admin: bool, + pub perms: crate::auth::Perms, + pub disabled: bool, +} + +/// A change to the server's accounts (administrators only). +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(tag = "op", rename_all = "snake_case")] +pub enum UserOp { + Add { name: String, password: String, admin: bool, perms: crate::auth::Perms }, + Update { name: String, admin: bool, perms: crate::auth::Perms, disabled: bool }, + Password { name: String, password: String }, + Delete { name: String }, +} + +/// A folder on the server, as its file browser shows it. +#[derive(Serialize, Deserialize, Clone, Default, Debug)] +#[serde(default)] +pub struct FsList { + /// the folder listed (where the browser starts when none was asked for) + pub path: PathBuf, + pub parent: Option, + /// drives (Windows) or / , the library folders and the home folder + pub places: Vec, + pub entries: Vec, + /// why the folder couldn't be read (empty when it could) + pub error: String, +} + +#[derive(Serialize, Deserialize, Clone, Default, Debug)] +#[serde(default)] +pub struct FsEntry { + pub path: PathBuf, + pub dir: bool, + pub size: u64, +} + +/// List a folder for a file browser (reads the disk): its folders and audio files (`all`: every +/// file). An empty path starts at the first library folder, else the home folder. +pub fn list_folder(path: PathBuf, all: bool, library_folders: &[String]) -> FsList { + let home = dirs::home_dir(); + let start = || library_folders.first().map(PathBuf::from).or_else(|| home.clone()).unwrap_or_else(|| PathBuf::from("/")); + let path = if path.as_os_str().is_empty() { start() } else { path }; + let mut places = crate::library::drive_roots().to_vec(); + places.extend(library_folders.iter().map(PathBuf::from)); + places.extend(home.clone()); + let mut out = FsList { parent: path.parent().map(|p| p.to_path_buf()), places, ..Default::default() }; + match std::fs::read_dir(&path) { + Ok(rd) => { + out.entries = rd + .filter_map(|e| e.ok()) + .filter_map(|e| { + // (follows links, so a linked folder opens like any other) + let p = e.path(); + let m = std::fs::metadata(&p).ok()?; + let dir = m.is_dir(); + (dir || all || crate::library::is_audio(&p)).then_some(FsEntry { dir, size: if dir { 0 } else { m.len() }, path: p }) + }) + .collect(); + out.entries.sort_by(|a, b| { + b.dir.cmp(&a.dir).then_with(|| { + let name = |p: &PathBuf| p.file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default(); + name(&a.path).cmp(&name(&b.path)) + }) + }); + } + Err(e) => out.error = e.to_string(), + } + out.path = path; + out +} + #[derive(Serialize, Deserialize, Clone, Default, Debug)] #[serde(default)] pub struct TabWire { @@ -222,8 +348,17 @@ pub struct WaveReply { #[serde(default)] pub struct ServerInfo { pub name: String, + /// the account signed in pub user: String, + /// it is an administrator (only they change these settings, accounts and updates) + pub admin: bool, pub version: String, + /// a newer RadioMaster Server release (empty: up to date, or not checked yet) + pub latest: String, + /// that release has a download for the server's system, so it can update itself + pub can_update: bool, + /// what an update or restart is doing now (empty: nothing) + pub update_state: String, pub port: u16, /// start playing when the server starts pub autoplay: bool, @@ -245,9 +380,7 @@ pub struct ServerInfo { pub portal_port: u16, /// the saved HTTPS / portal settings differ from what is running pub restart_needed: bool, - /// a newer RadioMaster release than this server (empty: up to date, or not known yet) - pub latest: String, - /// its release page + /// the newer release's page pub release_page: String, /// when the certificate in use runs out (unix seconds; 0 without HTTPS) pub cert_expires: i64, @@ -292,6 +425,103 @@ pub struct ServerUpdate { pub tunnel_token: Option, } +// ------------------------------------------------------------ station profiles onto a server + +/// The text looks like a full path on Windows (C:\\…, \\\\server\\…) or elsewhere (/…). +fn is_abs_path(s: &str) -> bool { + let b = s.as_bytes(); + (b.len() > 1 && b[0] == b'/' && b[1] != b'/') + || (b.len() > 2 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')) + || s.starts_with("\\\\") +} + +/// "C:\\Music\\x.mp3" → "c:/music/x.mp3" for Windows-style paths (which ignore case), "/srv/x" as is. +fn path_key(s: &str) -> String { + let unified = s.replace('\\', "/"); + if s.contains('\\') || s.as_bytes().get(1) == Some(&b':') { + unified.to_lowercase() + } else { + unified + } +} + +fn each_string(v: &serde_json::Value, f: &mut dyn FnMut(&str)) { + match v { + serde_json::Value::String(s) => f(s), + serde_json::Value::Array(a) => a.iter().for_each(|x| each_string(x, f)), + serde_json::Value::Object(o) => o.values().for_each(|x| each_string(x, f)), + _ => {} + } +} + +/// The folders a station profile's file paths are under, as few as cover them all (a file's own +/// folder, or the path itself when it has no extension), for mapping them to the server's folders. +pub fn path_roots(v: &serde_json::Value) -> Vec { + let mut dirs: Vec = vec![]; + each_string(v, &mut |s| { + if !is_abs_path(s) { + return; + } + let unified = s.trim_end_matches(['/', '\\']).replace('\\', "/"); + let last = unified.rsplit('/').next().unwrap_or(""); + let dir = + if last.contains('.') { unified.rsplit_once('/').map(|(d, _)| d.to_string()).unwrap_or(unified.clone()) } else { unified }; + // back in the profile's own style + let dir = if s.contains('\\') { dir.replace('/', "\\") } else { dir }; + if !dir.is_empty() && !dirs.contains(&dir) { + dirs.push(dir); + } + }); + dirs.sort_by_key(|d| d.len()); + let mut roots: Vec = vec![]; + for d in dirs { + let k = path_key(&d); + if !roots.iter().any(|r| { + let rk = path_key(r); + k == rk || k.starts_with(&format!("{}/", rk.trim_end_matches('/'))) + }) { + roots.push(d); + } + } + roots +} + +/// `path` moved from under `from` to under `to` (None: it isn't under `from`). The rest of the path +/// takes `to`'s separators. +pub fn map_path(path: &str, from: &str, to: &str) -> Option { + let (pk, fk) = (path_key(path), path_key(from.trim_end_matches(['/', '\\']))); + if fk.is_empty() || !(pk == fk || pk.starts_with(&format!("{fk}/"))) { + return None; + } + let rest = path.replace('\\', "/")[fk.len()..].to_string(); + let windows = to.contains('\\') || to.as_bytes().get(1) == Some(&b':'); + let rest = if windows { rest.replace('/', "\\") } else { rest }; + Some(format!("{}{rest}", to.trim_end_matches(['/', '\\']))) +} + +/// Move every path in a station profile with the first mapping (from, to) it fits. Returns how +/// many paths changed. +pub fn map_paths(v: &mut serde_json::Value, maps: &[(String, String)]) -> usize { + match v { + serde_json::Value::String(s) if is_abs_path(s) => { + match maps + .iter() + .filter(|(f, t)| !f.trim().is_empty() && !t.trim().is_empty()) + .find_map(|(f, t)| map_path(s, f.trim(), t.trim())) + { + Some(n) => { + *s = n; + 1 + } + None => 0, + } + } + serde_json::Value::Array(a) => a.iter_mut().map(|x| map_paths(x, maps)).sum(), + serde_json::Value::Object(o) => o.values_mut().map(|x| map_paths(x, maps)).sum(), + _ => 0, + } +} + /// Playlist files that are missing (on the server, which is the machine that plays them). pub fn missing_in(t: &TabsWire) -> Vec { let mut v: Vec = t @@ -717,6 +947,45 @@ mod tests { assert_eq!(m.playing_tab, 0); } + #[test] + fn profile_paths_map_to_the_server() { + let v = serde_json::json!({ + "library_folders": ["C:\\Users\\DJ\\Music"], + "carts": [{"path": "C:\\Users\\DJ\\Music\\Jingles\\id.mp3"}, {"path": ""}], + "ads": [{"path": "D:\\Ads\\spring.mp3"}], + "azuracast": {"base_url": "https://radio.example.com/api"}, + "_session": {"tabs": [{"entries": [{"track": {"path": "/home/dj/show/a.flac"}}]}]} + }); + let roots = path_roots(&v); + assert_eq!(roots, vec!["D:\\Ads".to_string(), "/home/dj/show".into(), "C:\\Users\\DJ\\Music".into()]); + let mut m = v.clone(); + let maps = vec![("c:\\users\\dj\\music".to_string(), "/srv/music".to_string()), ("D:\\Ads".into(), "E:\\Station\\Ads".into())]; + assert_eq!(map_paths(&mut m, &maps), 3); + assert_eq!(m["library_folders"][0], "/srv/music"); + assert_eq!(m["carts"][0]["path"], "/srv/music/Jingles/id.mp3"); + assert_eq!(m["ads"][0]["path"], "E:\\Station\\Ads\\spring.mp3"); + // not mapped, and not paths at all, stay as they were + assert_eq!(m["_session"]["tabs"][0]["entries"][0]["track"]["path"], "/home/dj/show/a.flac"); + assert_eq!(m["azuracast"]["base_url"], "https://radio.example.com/api"); + // a folder whose name only starts the same isn't under it + assert_eq!(map_path("/srv/musicals/x.mp3", "/srv/music", "/data"), None); + } + + #[test] + fn permissions_limit_what_an_account_changes() { + let p = crate::auth::Perms::presenter(); + let old = Config::default(); + let mut new = old.clone(); + new.carts.resize(24, Default::default()); + new.carts[0].path = "/srv/jingles/id.mp3".into(); + assert_eq!(cfg_denied(&old, &new, &p), None, "presenters may set up jingles"); + new.crossfade_secs = 9.0; + assert_eq!(cfg_denied(&old, &new, &p).as_deref(), Some("crossfade_secs")); + assert_eq!(cfg_denied(&old, &new, &crate::auth::Perms::default()), None); + assert!(Cmd::Next.allowed(&p) && !Cmd::EncoderStart { i: 0 }.allowed(&p)); + assert!(!Cmd::Generate { tab: 0, name: "Hits".into(), append: false }.allowed(&p)); + } + #[test] fn waveforms_survive_the_trip() { let w = Waveform { peaks: vec![0.0, 0.5, 1.0, 2.0], rms: vec![0.0, 0.25, 0.7, 0.001] }; diff --git a/src/tools.rs b/src/tools.rs index 0892b89..5ada6d0 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -19,10 +19,11 @@ pub(super) enum ToolWin { Plugins, Scheduler, Cleanup, + FileExplorer, } impl ToolWin { - pub(super) const ALL: [ToolWin; 12] = [ + pub(super) const ALL: [ToolWin; 13] = [ ToolWin::FadeEditor, ToolWin::Generator, ToolWin::Ads, @@ -35,6 +36,7 @@ impl ToolWin { ToolWin::Plugins, ToolWin::Scheduler, ToolWin::Cleanup, + ToolWin::FileExplorer, ]; /// Whether an operator with these permissions may open this tool. pub(super) fn allowed(self, p: &crate::auth::Perms) -> bool { @@ -52,6 +54,7 @@ impl ToolWin { ToolWin::History => p.tool(tool::HISTORY), ToolWin::Notepad => p.tool(tool::NOTEPAD), ToolWin::Cleanup => p.tool(tool::LIBRARY_CLEANUP), + ToolWin::FileExplorer => p.tool(tool::FILE_EXPLORER), } } @@ -69,6 +72,7 @@ impl ToolWin { ToolWin::Plugins => "Plugins", ToolWin::Scheduler => "Event scheduler", ToolWin::Cleanup => "Library cleanup", + ToolWin::FileExplorer => "File explorer", } } pub(super) fn icon(self) -> &'static str { @@ -85,6 +89,7 @@ impl ToolWin { ToolWin::Plugins => "🔌", ToolWin::Scheduler => "⏰", ToolWin::Cleanup => "✨", + ToolWin::FileExplorer => "📁", } } fn size(self) -> [f32; 2] { @@ -101,6 +106,7 @@ impl ToolWin { ToolWin::Plugins => [720.0, 520.0], ToolWin::Scheduler => [900.0, 560.0], ToolWin::Cleanup => [900.0, 600.0], + ToolWin::FileExplorer => [640.0, 600.0], } } } @@ -376,6 +382,12 @@ impl App { self.tab_cleanup(ui, core); // its lists scroll on their own return; } + if w == ToolWin::FileExplorer { + egui::ScrollArea::vertical().id_salt("fx_window").auto_shrink([false, false]).show(ui, |ui| { + self.tab_file_explorer(ui, "window".into(), core); + }); + return; + } egui::ScrollArea::both().id_salt(("tool_scroll", w as u8)).auto_shrink([false, false]).show(ui, |ui| match w { ToolWin::Generator => self.tab_generator(ui, core), ToolWin::Ads => self.tab_ads(ui, core), @@ -387,7 +399,7 @@ impl App { ToolWin::AzuraCast => {} ToolWin::Reports => self.tab_reports(ui, core), ToolWin::History => self.tab_history(ui, core), - ToolWin::FadeEditor | ToolWin::Cleanup => {} + ToolWin::FadeEditor | ToolWin::Cleanup | ToolWin::FileExplorer => {} }); }, );