diff --git a/.github/workflows/aur-bin.yml b/.github/workflows/aur-bin.yml new file mode 100644 index 0000000..161e9af --- /dev/null +++ b/.github/workflows/aur-bin.yml @@ -0,0 +1,85 @@ +name: aur-bin + +on: + release: + types: + - published + workflow_dispatch: + inputs: + version: + description: Version to publish without the leading v + required: true + source_url: + description: Release archive URL + required: true + sha256: + description: SHA-256 checksum for the release archive + required: true + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Resolve release metadata + id: release + run: | + if [[ "${GITHUB_EVENT_NAME}" == "release" ]]; then + python - <<'PY' >> "$GITHUB_OUTPUT" + import json + import urllib.request + from pathlib import Path + + event = json.loads(Path('${{ github.event_path }}').read_text()) + tag_name = event['release']['tag_name'] + version = tag_name.removeprefix('v') + tarball = None + checksum = None + for asset in event['release']['assets']: + name = asset['name'] + if name == f'orators-{tag_name}-x86_64-unknown-linux-gnu.tar.gz': + tarball = asset['browser_download_url'] + elif name == f'orators-{tag_name}-x86_64-unknown-linux-gnu.tar.gz.sha256': + checksum = asset['browser_download_url'] + if tarball is None or checksum is None: + raise SystemExit('release assets are missing the expected archive or checksum') + with urllib.request.urlopen(checksum) as response: + sha256 = response.read().decode().split()[0] + print(f'version={version}') + print(f'source_url={tarball}') + print(f'sha256={sha256}') + PY + else + echo "version=${{ inputs.version }}" >> "$GITHUB_OUTPUT" + echo "source_url=${{ inputs.source_url }}" >> "$GITHUB_OUTPUT" + echo "sha256=${{ inputs.sha256 }}" >> "$GITHUB_OUTPUT" + fi + + - name: Prepare AUR SSH key + env: + AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + run: | + install -d -m 700 ~/.ssh + printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > ~/.ssh/aur + chmod 600 ~/.ssh/aur + ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts + echo 'GIT_SSH_COMMAND=ssh -i ~/.ssh/aur -o IdentitiesOnly=yes' >> "$GITHUB_ENV" + + - name: Render orators-bin package + run: | + ./scripts/aur/render_orators_bin_pkgbuild.py \ + --version "${{ steps.release.outputs.version }}" \ + --source-url "${{ steps.release.outputs.source_url }}" \ + --sha256 "${{ steps.release.outputs.sha256 }}" \ + --output-dir /tmp/orators-bin + + - name: Publish to AUR + env: + AUR_PACKAGER_NAME: ${{ secrets.AUR_PACKAGER_NAME }} + AUR_PACKAGER_EMAIL: ${{ secrets.AUR_PACKAGER_EMAIL }} + run: | + ./scripts/aur/publish_aur_package.sh \ + orators-bin \ + /tmp/orators-bin \ + "update: orators-bin ${{ steps.release.outputs.version }}" diff --git a/.github/workflows/aur-git.yml b/.github/workflows/aur-git.yml new file mode 100644 index 0000000..fd89899 --- /dev/null +++ b/.github/workflows/aur-git.yml @@ -0,0 +1,43 @@ +name: aur-git + +on: + push: + branches: + - main + paths: + - 'packaging/aur/orators-git/**' + - 'packaging/systemd/user/oratorsd.service' + - '.github/workflows/aur-git.yml' + - 'scripts/aur/generate_srcinfo.sh' + - 'scripts/aur/publish_aur_package.sh' + workflow_dispatch: + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Prepare AUR SSH key + env: + AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + run: | + install -d -m 700 ~/.ssh + printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > ~/.ssh/aur + chmod 600 ~/.ssh/aur + ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts + echo 'GIT_SSH_COMMAND=ssh -i ~/.ssh/aur -o IdentitiesOnly=yes' >> "$GITHUB_ENV" + + - name: Stage orators-git package + run: | + rsync -a --delete packaging/aur/orators-git/ /tmp/orators-git/ + + - name: Publish to AUR + env: + AUR_PACKAGER_NAME: ${{ secrets.AUR_PACKAGER_NAME }} + AUR_PACKAGER_EMAIL: ${{ secrets.AUR_PACKAGER_EMAIL }} + run: | + ./scripts/aur/publish_aur_package.sh \ + orators-git \ + /tmp/orators-git \ + "update: refresh orators-git packaging" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff1fe51..9587814 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,4 +23,6 @@ jobs: run: cargo test --workspace - name: Nix flake check run: nix flake check + - name: Validate release and AUR packaging + run: ./scripts/validate_packaging.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0e6bc22 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,57 @@ +name: release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + github-release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Verify tag matches workspace version + run: | + tag_version="${GITHUB_REF_NAME#v}" + workspace_version=$(python - <<'PY' + import json, subprocess + metadata = json.loads(subprocess.check_output([ + 'cargo', 'metadata', '--no-deps', '--format-version', '1' + ], text=True)) + for package in metadata['packages']: + if package['name'] == 'orators': + print(package['version']) + break + else: + raise SystemExit('could not resolve orators package version') + PY + ) + if [[ "$tag_version" != "$workspace_version" ]]; then + echo "Tag version $tag_version does not match workspace version $workspace_version" >&2 + exit 1 + fi + + - name: Build release archive + id: bundle + run: | + archive_path=$(./scripts/release/build-release-archive.sh --version "${GITHUB_REF_NAME#v}") + echo "archive_path=$archive_path" >> "$GITHUB_OUTPUT" + echo "checksum_path=${archive_path}.sha256" >> "$GITHUB_OUTPUT" + + - name: Publish GitHub release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "$GITHUB_REF_NAME" \ + "${{ steps.bundle.outputs.archive_path }}" \ + "${{ steps.bundle.outputs.checksum_path }}" \ + --title "orators ${GITHUB_REF_NAME#v}" \ + --notes "Automated release for orators ${GITHUB_REF_NAME#v}." diff --git a/.gitignore b/.gitignore index 5da421c..ac0ef20 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ /result /.direnv + +__pycache__/ +*.pyc diff --git a/README.md b/README.md index 2dabb32..b10d664 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,23 @@ nix develop nix flake check ``` +## Arch Linux / AUR + +- `orators-bin`: prebuilt `x86_64` binaries from GitHub tagged releases +- `orators-git`: latest GitHub `main`, built locally by makepkg +- Both AUR variants currently depend on `bluez-alsa-git` for the BlueALSA runtime + +Example install: + +```bash +paru -S orators-bin +systemctl --user daemon-reload +systemctl --user enable --now oratorsd.service +oratorsctl install-system-backend +``` + +`oratorsctl install-user-service` remains useful for manual tarball installs, but packaged installs should prefer the shipped systemd user unit. + ## Service Model - `orators` opens the TUI diff --git a/crates/orators-linux/src/bluealsa.rs b/crates/orators-linux/src/bluealsa.rs index a080c70..a2fae1e 100644 --- a/crates/orators-linux/src/bluealsa.rs +++ b/crates/orators-linux/src/bluealsa.rs @@ -13,6 +13,7 @@ use tokio::{process::Child, process::Command, sync::Mutex}; const PLAYER_RESTART_BACKOFF: Duration = Duration::from_secs(3); const PLAYER_MAX_RESTARTS: u8 = 3; +const PLAYER_VOLUME_MODE: &str = "--volume=software"; pub const SYSTEM_BACKEND_UNIT: &str = "orators-bluealsad.service"; const TRUSTED_BLUEALSA_DIRS: &[&str] = &[ "/usr/libexec/orators/bluealsa", @@ -207,15 +208,16 @@ impl BluealsaRuntime { .context("missing active device address for BlueALSA playback")?; let child = Command::new(&assets.bluealsa_aplay) - .arg(&address) + .args(player_args(&address)) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .with_context(|| { format!( - "failed to start `{} {address}`", - assets.bluealsa_aplay.display() + "failed to start `{} {} {address}`", + assets.bluealsa_aplay.display(), + PLAYER_VOLUME_MODE ) }); @@ -341,6 +343,10 @@ impl PlayerSupervisor { } } +fn player_args(address: &str) -> [&str; 2] { + [PLAYER_VOLUME_MODE, address] +} + #[cfg(test)] fn find_binary_in_path(name: &str, path: &OsStr) -> Option { env::split_paths(path) @@ -375,7 +381,10 @@ mod tests { use orators_core::PlayerState; use tempfile::tempdir; - use super::{BluealsaAssets, PLAYER_MAX_RESTARTS, PlayerSupervisor, find_binary_in_path}; + use super::{ + BluealsaAssets, PLAYER_MAX_RESTARTS, PLAYER_VOLUME_MODE, PlayerSupervisor, + find_binary_in_path, player_args, + }; #[test] fn finds_executable_in_path() { @@ -452,4 +461,12 @@ mod tests { assert_eq!(supervisor.restart_attempts, PLAYER_MAX_RESTARTS); assert!(supervisor.next_restart_at.is_some()); } + + #[test] + fn player_launch_forces_software_volume_mode() { + assert_eq!( + player_args("AA:BB:CC:DD:EE:FF"), + [PLAYER_VOLUME_MODE, "AA:BB:CC:DD:EE:FF"] + ); + } } diff --git a/crates/orators/src/control.rs b/crates/orators/src/control.rs index 73da418..0dbef55 100644 --- a/crates/orators/src/control.rs +++ b/crates/orators/src/control.rs @@ -302,7 +302,7 @@ async fn ensure_daemon_running(connection: &Connection) -> Result<()> { SystemdUserRuntime .start_orators_service() .await - .context("failed to start oratorsd.service; run `oratorsctl install-user-service` first")?; + .context("failed to start oratorsd.service; enable the packaged unit with `systemctl --user enable --now oratorsd.service` or run `oratorsctl install-user-service` first")?; for _ in 0..12 { if bus diff --git a/crates/orators/src/tui.rs b/crates/orators/src/tui.rs index 2baf611..612a8e5 100644 --- a/crates/orators/src/tui.rs +++ b/crates/orators/src/tui.rs @@ -62,6 +62,18 @@ impl View { View::Logs => "Logs", } } + + fn from_shortcut(shortcut: char) -> Option { + match shortcut { + '1' => Some(View::Dashboard as usize), + '2' => Some(View::Devices as usize), + '3' => Some(View::Pairing as usize), + '4' => Some(View::Settings as usize), + '5' => Some(View::Setup as usize), + '6' => Some(View::Logs as usize), + _ => None, + } + } } #[derive(Debug, Clone)] @@ -70,6 +82,14 @@ enum InputMode { EditAlias { address: String, value: String }, EditPairingTimeout { value: String }, EditAdapter { value: String }, + Confirm(ConfirmAction), +} + +#[derive(Debug, Clone)] +enum ConfirmAction { + ForgetDevice { address: String, label: String }, + ResetDevice { address: String, label: String }, + UninstallBackend, } #[derive(Clone, Copy)] @@ -146,6 +166,12 @@ impl App { }; } + fn jump_to_view(&mut self, index: usize) { + if index < View::ALL.len() { + self.view = index; + } + } + fn push_message(&mut self, message: impl Into) { let message = message.into(); if self @@ -168,6 +194,11 @@ impl App { .and_then(|status| status.devices.get(self.selected_device)) } + fn selected_setting_item(&self) -> Option { + let items = self.settings_items(); + items.get(self.selected_setting).map(|(item, _, _)| *item) + } + fn settings_items(&self) -> Vec<(SettingItem, String, String)> { let mut items = vec![ ( @@ -271,6 +302,7 @@ impl App { InputMode::EditAlias { .. } => self.handle_alias_input(key).await, InputMode::EditPairingTimeout { .. } => self.handle_pairing_timeout_input(key).await, InputMode::EditAdapter { .. } => self.handle_adapter_input(key).await, + InputMode::Confirm(_) => self.handle_confirm_input(terminal, key).await, } } @@ -397,11 +429,82 @@ impl App { Ok(()) } + async fn handle_confirm_input( + &mut self, + terminal: &mut TuiTerminal, + key: KeyEvent, + ) -> Result<()> { + let action = match std::mem::replace(&mut self.input_mode, InputMode::Normal) { + InputMode::Confirm(action) => action, + other => { + self.input_mode = other; + return Ok(()); + } + }; + + match key.code { + KeyCode::Esc | KeyCode::Char('n') => {} + KeyCode::Enter | KeyCode::Char('y') => { + self.execute_confirm_action(terminal, action).await?; + } + _ => { + self.input_mode = InputMode::Confirm(action); + } + } + + Ok(()) + } + + async fn execute_confirm_action( + &mut self, + terminal: &mut TuiTerminal, + action: ConfirmAction, + ) -> Result<()> { + match action { + ConfirmAction::ForgetDevice { address, label } => { + let client = ControllerClient::connect().await?; + client.forget_device(&address).await?; + self.push_message(format!("Forgot {label}.")); + self.refresh().await; + } + ConfirmAction::ResetDevice { address, label } => { + let client = ControllerClient::connect().await?; + if self + .status + .as_ref() + .and_then(|status| { + status + .devices + .iter() + .find(|device| device.address == address) + .map(|device| device.connected) + }) + .unwrap_or(false) + { + client.disconnect_device(&address).await?; + } + client.forget_device(&address).await?; + self.push_message(format!("Reset {label} on the host.")); + self.refresh().await; + } + ConfirmAction::UninstallBackend => { + self.run_uninstall_flow(terminal).await?; + } + } + + Ok(()) + } + async fn handle_normal_key(&mut self, terminal: &mut TuiTerminal, key: KeyEvent) -> Result<()> { match key.code { KeyCode::Char('q') => self.should_quit = true, - KeyCode::Tab => self.next_view(), - KeyCode::BackTab => self.previous_view(), + KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => self.next_view(), + KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => self.previous_view(), + KeyCode::Char(c) if c.is_ascii_digit() => { + if let Some(index) = View::from_shortcut(c) { + self.jump_to_view(index); + } + } KeyCode::Char('r') => self.refresh().await, KeyCode::Down | KeyCode::Char('j') => match self.current_view() { View::Devices => { @@ -447,7 +550,9 @@ impl App { match key.code { KeyCode::Char('p') => self.toggle_pairing().await?, KeyCode::Char('i') => self.run_install_flow(terminal).await?, - KeyCode::Char('u') => self.run_uninstall_flow(terminal).await?, + KeyCode::Char('u') => { + self.input_mode = InputMode::Confirm(ConfirmAction::UninstallBackend) + } _ => {} } Ok(()) @@ -487,28 +592,33 @@ impl App { } self.refresh().await; } - KeyCode::Char('c') => { + KeyCode::Enter | KeyCode::Char('c') => { if device.connected { client.disconnect_device(&device.address).await?; - self.push_message(format!("Disconnected {}.", device.address)); + self.push_message(format!( + "Disconnected {}.", + device_label(device.alias.as_deref(), &device.address) + )); } else { client.connect_device(&device.address).await?; - self.push_message(format!("Connect requested for {}.", device.address)); + self.push_message(format!( + "Connect requested for {}.", + device_label(device.alias.as_deref(), &device.address) + )); } self.refresh().await; } KeyCode::Char('x') => { - if device.connected { - client.disconnect_device(&device.address).await?; - } - client.forget_device(&device.address).await?; - self.push_message(format!("Reset {} on the host.", device.address)); - self.refresh().await; + self.input_mode = InputMode::Confirm(ConfirmAction::ResetDevice { + address: device.address.clone(), + label: device_label(device.alias.as_deref(), &device.address), + }); } KeyCode::Char('f') => { - client.forget_device(&device.address).await?; - self.push_message(format!("Forgot {}.", device.address)); - self.refresh().await; + self.input_mode = InputMode::Confirm(ConfirmAction::ForgetDevice { + address: device.address.clone(), + label: device_label(device.alias.as_deref(), &device.address), + }); } KeyCode::Char('n') => { self.input_mode = InputMode::EditAlias { @@ -570,7 +680,9 @@ impl App { async fn handle_setup_key(&mut self, terminal: &mut TuiTerminal, key: KeyEvent) -> Result<()> { match key.code { KeyCode::Char('i') => self.run_install_flow(terminal).await?, - KeyCode::Char('u') => self.run_uninstall_flow(terminal).await?, + KeyCode::Char('u') => { + self.input_mode = InputMode::Confirm(ConfirmAction::UninstallBackend) + } _ => {} } Ok(()) @@ -639,6 +751,7 @@ impl App { .direction(Direction::Vertical) .constraints([ Constraint::Length(3), + Constraint::Length(4), Constraint::Min(0), Constraint::Length(3), ]) @@ -654,22 +767,98 @@ impl App { .highlight_style(Style::default().fg(Color::Yellow)); frame.render_widget(tabs, layout[0]); + let banner = Paragraph::new(self.banner_lines()) + .block(Block::default().borders(Borders::ALL).title("Overview")) + .wrap(Wrap { trim: true }); + frame.render_widget(banner, layout[1]); + match self.current_view() { - View::Dashboard => self.draw_dashboard(frame, layout[1]), - View::Devices => self.draw_devices(frame, layout[1]), - View::Pairing => self.draw_pairing(frame, layout[1]), - View::Settings => self.draw_settings(frame, layout[1]), - View::Setup => self.draw_setup(frame, layout[1]), - View::Logs => self.draw_logs(frame, layout[1]), + View::Dashboard => self.draw_dashboard(frame, layout[2]), + View::Devices => self.draw_devices(frame, layout[2]), + View::Pairing => self.draw_pairing(frame, layout[2]), + View::Settings => self.draw_settings(frame, layout[2]), + View::Setup => self.draw_setup(frame, layout[2]), + View::Logs => self.draw_logs(frame, layout[2]), } let footer = Paragraph::new(self.footer_text()) .block(Block::default().borders(Borders::ALL).title("Keys")); - frame.render_widget(footer, layout[2]); + frame.render_widget(footer, layout[3]); self.draw_modal(frame); } + fn banner_lines(&self) -> Vec> { + let mut line_one = vec![ + Span::styled( + if self.connection_error.is_some() { + "Daemon offline" + } else { + "Daemon online" + }, + Style::default() + .fg(if self.connection_error.is_some() { + Color::Red + } else { + Color::Green + }) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::raw(format!("View {} of {}", self.view + 1, View::ALL.len())), + ]; + + if let Some(status) = &self.status { + line_one.extend([ + Span::raw(" "), + Span::raw(format!( + "Active {}", + status.active_device.as_deref().unwrap_or("none") + )), + Span::raw(" "), + Span::raw(format!( + "Pairing {}", + if status.pairing.enabled { "on" } else { "off" } + )), + Span::raw(" "), + Span::raw(format!( + "Backend {}", + if status.backend.system_service_ready { + "ready" + } else { + "needs repair" + } + )), + ]); + } + + vec![Line::from(line_one), Line::from(self.banner_hint_text())] + } + + fn banner_hint_text(&self) -> String { + if let Some(error) = &self.connection_error { + return format!("Connection problem: {error}"); + } + + if self + .status + .as_ref() + .is_some_and(|status| !status.backend.system_service_ready) + { + return "Use Setup (5) to install or repair the managed backend.".to_string(); + } + + if self + .status + .as_ref() + .is_some_and(|status| status.devices.is_empty()) + { + return "No devices yet. Start pairing from Dashboard (1) or Pairing (3).".to_string(); + } + + "Use 1-6 to jump views, Left/Right to switch tabs, r to refresh, q to quit.".to_string() + } + fn draw_dashboard(&self, frame: &mut Frame<'_>, area: Rect) { let chunks = Layout::default() .direction(Direction::Vertical) @@ -680,6 +869,11 @@ impl App { ]) .split(area); + let top = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(58), Constraint::Percentage(42)]) + .split(chunks[0]); + let status_lines = if let Some(status) = &self.status { vec![ Line::from(format!( @@ -730,7 +924,18 @@ impl App { Paragraph::new(status_lines) .block(Block::default().borders(Borders::ALL).title("Status")) .wrap(Wrap { trim: true }), - chunks[0], + top[0], + ); + + frame.render_widget( + Paragraph::new(self.dashboard_action_lines()) + .block( + Block::default() + .borders(Borders::ALL) + .title("Quick Actions"), + ) + .wrap(Wrap { trim: true }), + top[1], ); let doctor_lines = self @@ -762,6 +967,18 @@ impl App { } fn draw_devices(&self, frame: &mut Frame<'_>, area: Rect) { + let layout = if area.width >= 100 { + Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(46), Constraint::Percentage(54)]) + .split(area) + } else { + Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(52), Constraint::Percentage(48)]) + .split(area) + }; + let items = self .status .as_ref() @@ -770,21 +987,17 @@ impl App { .devices .iter() .map(|device| { - let allowed = if self.config.allows_device(&device.address) { - " allowed" + let badges = device_badges(self.status.as_ref(), &self.config, device); + let text = if badges.is_empty() { + device_label(device.alias.as_deref(), &device.address) } else { - "" + format!( + "{} {}", + device_label(device.alias.as_deref(), &device.address), + badges + ) }; - let connected = if device.connected { " connected" } else { "" }; - let trusted = if device.trusted { " trusted" } else { "" }; - ListItem::new(format!( - "{} [{}]{}{}{}", - device.alias.as_deref().unwrap_or("unnamed"), - device.address, - allowed, - trusted, - connected - )) + ListItem::new(text) }) .collect::>() }) @@ -802,7 +1015,14 @@ impl App { .add_modifier(Modifier::BOLD), ) .highlight_symbol("> "); - frame.render_stateful_widget(list, area, &mut state); + frame.render_stateful_widget(list, layout[0], &mut state); + + frame.render_widget( + Paragraph::new(self.device_detail_lines()) + .block(Block::default().borders(Borders::ALL).title("Selection")) + .wrap(Wrap { trim: true }), + layout[1], + ); } fn draw_pairing(&self, frame: &mut Frame<'_>, area: Rect) { @@ -838,6 +1058,18 @@ impl App { } fn draw_settings(&self, frame: &mut Frame<'_>, area: Rect) { + let layout = if area.width >= 100 { + Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(44), Constraint::Percentage(56)]) + .split(area) + } else { + Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area) + }; + let items = self .settings_items() .into_iter() @@ -855,7 +1087,13 @@ impl App { .add_modifier(Modifier::BOLD), ) .highlight_symbol("> "); - frame.render_stateful_widget(list, area, &mut state); + frame.render_stateful_widget(list, layout[0], &mut state); + frame.render_widget( + Paragraph::new(self.setting_detail_lines()) + .block(Block::default().borders(Borders::ALL).title("Details")) + .wrap(Wrap { trim: true }), + layout[1], + ); } fn draw_setup(&self, frame: &mut Frame<'_>, area: Rect) { @@ -897,6 +1135,10 @@ impl App { .as_deref() .unwrap_or("Press `i` to install or repair the backend."), ), + Line::from(""), + Line::from("Suggested flow:"), + Line::from("1. Install or repair the backend with `i`."), + Line::from("2. Return to Pairing or Devices once the backend is ready."), ]; frame.render_widget( Paragraph::new(lines) @@ -910,6 +1152,152 @@ impl App { self.draw_logs_panel(frame, area, "Logs"); } + fn dashboard_action_lines(&self) -> Vec> { + let pairing_action = if self + .status + .as_ref() + .is_some_and(|status| status.pairing.enabled) + { + "`p` stop pairing mode".to_string() + } else { + format!( + "`p` start pairing for {} seconds", + self.config.pairing_timeout_secs + ) + }; + + vec![ + Line::from(pairing_action), + Line::from("`i` install or repair the managed backend"), + Line::from("`u` uninstall the backend (confirmation required)"), + Line::from("`2` jump straight to Devices after pairing"), + Line::from("`4` review settings like auto reconnect"), + ] + } + + fn device_detail_lines(&self) -> Vec> { + let Some(status) = &self.status else { + return vec![ + Line::from("No live device data yet."), + Line::from( + self.connection_error + .clone() + .unwrap_or_else(|| "Connect to the daemon to inspect devices.".to_string()), + ), + ]; + }; + + let Some(device) = self.selected_device() else { + return if status.devices.is_empty() { + vec![ + Line::from("No Bluetooth devices are known yet."), + Line::from("Start pairing from Dashboard (1) or Pairing (3)."), + Line::from( + "After a phone appears, return here to connect, trust, and rename it.", + ), + ] + } else { + vec![Line::from("Select a device to inspect it.")] + }; + }; + + let is_active = status.active_device.as_deref() == Some(device.address.as_str()); + let mut lines = vec![ + Line::from(device_label(device.alias.as_deref(), &device.address)), + Line::from(format!("Address: {}", device.address)), + Line::from(format!( + "State: paired={}, trusted={}, connected={}, active={}", + yes_no(device.paired), + yes_no(device.trusted), + yes_no(device.connected), + yes_no(is_active) + )), + Line::from(format!( + "Allowlisted: {}", + yes_no(self.config.allows_device(&device.address)) + )), + Line::from(format!("Auto reconnect: {}", yes_no(device.auto_reconnect))), + Line::from(format!( + "Profile: {}", + device + .active_profile + .as_ref() + .map(profile_label) + .unwrap_or("none") + )), + Line::from(""), + ]; + + if device.connected { + lines.push(Line::from( + "Primary action: Enter or `c` disconnects this device.", + )); + } else { + lines.push(Line::from( + "Primary action: Enter or `c` connects this device.", + )); + } + lines.push(Line::from( + "`a` toggles the allowlist entry used for auto-trust.", + )); + lines.push(Line::from("`t` toggles trust immediately on the host.")); + lines.push(Line::from( + "`n` renames the local alias. `N` clears that alias.", + )); + lines.push(Line::from( + "`f` forgets host pairing only. `x` disconnects if needed, then forgets.", + )); + lines.push(Line::from( + "Forget/reset keep the allowlist entry in place.", + )); + lines + } + + fn setting_detail_lines(&self) -> Vec> { + let Some(setting) = self.selected_setting_item() else { + return vec![Line::from("Select a setting to inspect it.")]; + }; + + match setting { + SettingItem::PairingTimeout => vec![ + Line::from(format!( + "Current timeout: {} seconds", + self.config.pairing_timeout_secs + )), + Line::from( + "Used when you start pairing from the Dashboard, Pairing, or Setup views.", + ), + Line::from("Press Enter to type a new timeout."), + ], + SettingItem::AutoReconnect => vec![ + Line::from(format!( + "Current value: {}", + yes_no(self.config.auto_reconnect) + )), + Line::from("Controls whether trusted devices are marked for automatic reconnect."), + Line::from("Press Enter or Space to toggle it immediately."), + ], + SettingItem::SingleActiveDevice => vec![ + Line::from(format!( + "Current value: {}", + yes_no(self.config.single_active_device) + )), + Line::from( + "When enabled, a second connect request is rejected while another device is active.", + ), + Line::from("Press Enter or Space to toggle it."), + ], + SettingItem::Adapter => vec![ + Line::from(format!( + "Current override: {}", + self.config.adapter.as_deref().unwrap_or("auto") + )), + Line::from("Only needed on hosts with multiple Bluetooth adapters."), + Line::from("Press Enter to edit it. Reinstall the backend after changing it."), + ], + } + } + fn draw_logs_panel(&self, frame: &mut Frame<'_>, area: Rect, title: &str) { let lines = if self.messages.is_empty() { vec![Line::from("No messages yet.")] @@ -929,45 +1317,100 @@ impl App { } fn draw_modal(&self, frame: &mut Frame<'_>) { - let (title, value, prompt) = match &self.input_mode { + let (title, lines) = match &self.input_mode { InputMode::Normal => return, - InputMode::EditAlias { value, .. } => ("Edit Alias", value.as_str(), "Enter alias"), - InputMode::EditPairingTimeout { value } => { - ("Pairing Timeout", value.as_str(), "Enter seconds") - } + InputMode::EditAlias { value, .. } => ( + "Edit Alias".to_string(), + vec![ + Line::from("Enter a friendly local name for this device."), + Line::from(""), + Line::from(value.to_string()), + Line::from(""), + Line::from("Enter to save, Esc to cancel."), + ], + ), + InputMode::EditPairingTimeout { value } => ( + "Pairing Timeout".to_string(), + vec![ + Line::from("Enter the default pairing window in seconds."), + Line::from(""), + Line::from(value.to_string()), + Line::from(""), + Line::from("Enter to save, Esc to cancel."), + ], + ), InputMode::EditAdapter { value } => ( - "Adapter", - value.as_str(), - "Enter hciX for multi-adapter setups", + "Adapter".to_string(), + vec![ + Line::from("Enter hciX only when you need a specific Bluetooth adapter."), + Line::from("Leave it blank to use auto mode."), + Line::from(""), + Line::from(value.to_string()), + Line::from(""), + Line::from("Enter to save, Esc to cancel."), + ], ), + InputMode::Confirm(action) => match action { + ConfirmAction::ForgetDevice { label, .. } => ( + "Forget Device".to_string(), + vec![ + Line::from(format!("Forget {label}?")), + Line::from("This removes host-side pairing state only."), + Line::from("The allowlist entry stays in place."), + Line::from(""), + Line::from("Enter or y to confirm. Esc or n to cancel."), + ], + ), + ConfirmAction::ResetDevice { label, .. } => ( + "Reset Device".to_string(), + vec![ + Line::from(format!("Reset {label}?")), + Line::from( + "This disconnects the device if needed, then forgets it on the host.", + ), + Line::from("The allowlist entry stays in place."), + Line::from(""), + Line::from("Enter or y to confirm. Esc or n to cancel."), + ], + ), + ConfirmAction::UninstallBackend => ( + "Uninstall Backend".to_string(), + vec![ + Line::from("Remove the managed backend?"), + Line::from( + "Bluetooth audio from Orators will stop until you reinstall it.", + ), + Line::from(""), + Line::from("Enter or y to confirm. Esc or n to cancel."), + ], + ), + }, }; - let area = centered_rect(60, 20, frame.area()); + let area = centered_rect(64, 28, frame.area()); frame.render_widget(Clear, area); - let modal = Paragraph::new(vec![ - Line::from(prompt), - Line::from(""), - Line::from(value.to_string()), - Line::from(""), - Line::from("Enter to save, Esc to cancel."), - ]) - .block(Block::default().borders(Borders::ALL).title(title)) - .wrap(Wrap { trim: true }); + let modal = Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL).title(title)) + .wrap(Wrap { trim: true }); frame.render_widget(modal, area); } fn footer_text(&self) -> Line<'static> { match self.current_view() { View::Dashboard => Line::from( - "Tab/Shift-Tab switch views, p pairing, i install, u uninstall, r refresh, q quit", + "1-6 views, Left/Right switch, p pairing, i install, u uninstall, r refresh, q quit", ), View::Devices => Line::from( - "j/k move, a allow, t trust, c connect, f forget, x reset, n alias, N clear alias", + "1-6 views, j/k move, Enter/c connect, a allow, t trust, f forget, x reset, n alias", ), View::Pairing => Line::from("p toggle pairing, r refresh, q quit"), - View::Settings => Line::from("j/k move, Enter edit/toggle setting, r refresh, q quit"), - View::Setup => Line::from("i install backend, u uninstall backend, r refresh, q quit"), - View::Logs => Line::from("Tab/Shift-Tab switch views, r refresh, q quit"), + View::Settings => { + Line::from("1-6 views, j/k move, Enter or Space edits/toggles, r refresh, q quit") + } + View::Setup => { + Line::from("1-6 views, i install backend, u uninstall backend, r refresh, q quit") + } + View::Logs => Line::from("1-6 views, Left/Right switch, r refresh, q quit"), } } } @@ -1047,10 +1490,50 @@ fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { .split(vertical[1])[1] } +fn device_label(alias: Option<&str>, address: &str) -> String { + match alias { + Some(alias) if alias != address => format!("{alias} [{address}]"), + _ => address.to_string(), + } +} + +fn device_badges( + status: Option<&RuntimeStatus>, + config: &OratorsConfig, + device: &DeviceInfo, +) -> String { + let mut badges = Vec::new(); + if status.is_some_and(|status| status.active_device.as_deref() == Some(device.address.as_str())) + { + badges.push("active"); + } + if device.connected { + badges.push("connected"); + } + if device.trusted { + badges.push("trusted"); + } + if config.allows_device(&device.address) { + badges.push("allowed"); + } + if badges.is_empty() { + String::new() + } else { + format!("[{}]", badges.join("] [")) + } +} + fn yes_no(value: bool) -> &'static str { if value { "yes" } else { "no" } } +fn profile_label(profile: &orators_core::BluetoothProfile) -> &'static str { + match profile { + orators_core::BluetoothProfile::Media => "media", + orators_core::BluetoothProfile::Call => "call", + } +} + fn player_state_label(state: &orators_core::PlayerState) -> &'static str { match state { orators_core::PlayerState::Waiting => "waiting", diff --git a/packaging/aur/README.md b/packaging/aur/README.md new file mode 100644 index 0000000..864a5e4 --- /dev/null +++ b/packaging/aur/README.md @@ -0,0 +1,49 @@ +# AUR packaging + +This repository tracks two AUR packages: + +- `orators-bin`: prebuilt `x86_64` binaries from GitHub tagged releases +- `orators-git`: latest `main` built directly from GitHub + +## Package layout + +- `packaging/systemd/user/oratorsd.service` is the packaged user unit installed by both AUR variants. +- `packaging/aur/orators-git/` is committed in the final AUR-ready form. +- `packaging/aur/orators-bin/PKGBUILD.in` is a template. The release/AUR automation renders it with the exact GitHub release asset URL and SHA-256 before pushing to AUR. + +## Why `bluez-alsa-git` + +As of April 16, 2026, the AUR RPC search for `bluez-alsa` returns `bluez-alsa-git`, so both Orators packages currently depend on that package for the BlueALSA runtime. + +## GitHub Actions secrets + +Set these repository secrets before enabling automatic AUR publishing: + +- `AUR_SSH_PRIVATE_KEY`: SSH private key for the AUR account that maintains `orators-bin` and `orators-git` +- `AUR_PACKAGER_NAME`: optional override for the git commit author name; defaults to `Jonatan Jonasson` +- `AUR_PACKAGER_EMAIL`: optional override for the git commit author email; defaults to `notes@madeingotland.com` + +## Release flow + +1. Bump the workspace version in `Cargo.toml`. +2. Create and push a tag like `v0.1.0`. +3. `.github/workflows/release.yml` builds the release archive and publishes it to GitHub Releases. +4. `.github/workflows/aur-bin.yml` renders the `orators-bin` PKGBUILD with the exact release URL and checksum, then pushes it to AUR. +5. `scripts/aur/publish_aur_package.sh` bootstraps the initial AUR git push to `master` if the package repo does not exist yet. + +## Updating `orators-git` + +The `orators-git` package does not need per-commit updates. Push its AUR repo only when packaging metadata changes, such as dependencies, install messaging, or service file packaging. + +## Local validation + +```bash +./scripts/release/build-release-archive.sh +./scripts/aur/render_orators_bin_pkgbuild.py \ + --version 0.1.0 \ + --source-url https://example.invalid/orators-v0.1.0-x86_64-unknown-linux-gnu.tar.gz \ + --sha256 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef \ + --output-dir /tmp/orators-bin +./scripts/aur/generate_srcinfo.sh /tmp/orators-bin/PKGBUILD > /tmp/orators-bin/.SRCINFO +./scripts/aur/generate_srcinfo.sh packaging/aur/orators-git/PKGBUILD > /tmp/orators-git.SRCINFO +``` diff --git a/packaging/aur/orators-bin/PKGBUILD.in b/packaging/aur/orators-bin/PKGBUILD.in new file mode 100644 index 0000000..b260e4b --- /dev/null +++ b/packaging/aur/orators-bin/PKGBUILD.in @@ -0,0 +1,39 @@ +# Maintainer: Jonatan Jonasson + +pkgname=orators-bin +pkgver=@PKGVER@ +pkgrel=1 +pkgdesc='Turn a Linux desktop into a Bluetooth speaker' +arch=('x86_64') +url='https://github.com/OneNoted/orators' +license=('MIT') +depends=( + 'bluez' + 'bluez-utils' + 'bluez-alsa-git' + 'pipewire' + 'sudo' + 'systemd' + 'wireplumber' +) +provides=("orators=${pkgver}") +conflicts=('orators' 'orators-git') +install='orators.install' +source=('@SOURCE_URL@') +sha256sums=('@SHA256@') + +_archive_root="orators-v${pkgver}-x86_64-unknown-linux-gnu" + +package() { + install -Dm755 "${srcdir}/${_archive_root}/bin/orators" "${pkgdir}/usr/bin/orators" + install -Dm755 "${srcdir}/${_archive_root}/bin/oratorsctl" "${pkgdir}/usr/bin/oratorsctl" + install -Dm755 "${srcdir}/${_archive_root}/bin/oratorsd" "${pkgdir}/usr/bin/oratorsd" + + install -Dm644 "${srcdir}/${_archive_root}/systemd/user/oratorsd.service" \ + "${pkgdir}/usr/lib/systemd/user/oratorsd.service" + + install -Dm644 "${srcdir}/${_archive_root}/README.md" \ + "${pkgdir}/usr/share/doc/orators/README.md" + install -Dm644 "${srcdir}/${_archive_root}/LICENSE" \ + "${pkgdir}/usr/share/licenses/orators/LICENSE" +} diff --git a/packaging/aur/orators-bin/orators.install b/packaging/aur/orators-bin/orators.install new file mode 100644 index 0000000..d940084 --- /dev/null +++ b/packaging/aur/orators-bin/orators.install @@ -0,0 +1,18 @@ +post_install() { + cat <<'EOM' +==> Orators was installed. +==> Enable the packaged user service for your account: + systemctl --user daemon-reload + systemctl --user enable --now oratorsd.service + +==> Then install the managed Bluetooth backend once: + oratorsctl install-system-backend + +==> Runtime note: Orators currently expects BlueALSA binaries to be present. +==> This package depends on bluez-alsa-git because that is the matching AUR package currently available. +EOM +} + +post_upgrade() { + post_install +} diff --git a/packaging/aur/orators-git/PKGBUILD b/packaging/aur/orators-git/PKGBUILD new file mode 100644 index 0000000..ae1eb09 --- /dev/null +++ b/packaging/aur/orators-git/PKGBUILD @@ -0,0 +1,58 @@ +# Maintainer: Jonatan Jonasson + +pkgname=orators-git +pkgver=0.0.0.r0.g0000000 +pkgrel=1 +pkgdesc='Turn a Linux desktop into a Bluetooth speaker' +arch=('x86_64') +url='https://github.com/OneNoted/orators' +license=('MIT') +depends=( + 'bluez' + 'bluez-utils' + 'bluez-alsa-git' + 'pipewire' + 'sudo' + 'systemd' + 'wireplumber' +) +makedepends=( + 'cargo' + 'git' +) +provides=('orators') +conflicts=('orators' 'orators-bin') +install='orators.install' +source=('orators::git+https://github.com/OneNoted/orators.git') +sha256sums=('SKIP') + +pkgver() { + cd "$srcdir/orators" + + if tag=$(git describe --long --tags --abbrev=7 --match 'v[0-9]*' 2>/dev/null); then + printf '%s\n' "${tag#v}" | sed 's/-/.r/; s/-/./g' + else + printf '0.0.0.r%s.g%s\n' \ + "$(git rev-list --count HEAD)" \ + "$(git rev-parse --short=7 HEAD)" + fi +} + +build() { + cd "$srcdir/orators" + cargo build --locked --release -p orators +} + +package() { + cd "$srcdir/orators" + + install -Dm755 target/release/orators "${pkgdir}/usr/bin/orators" + install -Dm755 target/release/oratorsctl "${pkgdir}/usr/bin/oratorsctl" + install -Dm755 target/release/oratorsd "${pkgdir}/usr/bin/oratorsd" + + install -Dm644 packaging/systemd/user/oratorsd.service \ + "${pkgdir}/usr/lib/systemd/user/oratorsd.service" + + install -Dm644 README.md "${pkgdir}/usr/share/doc/orators/README.md" + install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/orators/LICENSE" +} diff --git a/packaging/aur/orators-git/orators.install b/packaging/aur/orators-git/orators.install new file mode 100644 index 0000000..d940084 --- /dev/null +++ b/packaging/aur/orators-git/orators.install @@ -0,0 +1,18 @@ +post_install() { + cat <<'EOM' +==> Orators was installed. +==> Enable the packaged user service for your account: + systemctl --user daemon-reload + systemctl --user enable --now oratorsd.service + +==> Then install the managed Bluetooth backend once: + oratorsctl install-system-backend + +==> Runtime note: Orators currently expects BlueALSA binaries to be present. +==> This package depends on bluez-alsa-git because that is the matching AUR package currently available. +EOM +} + +post_upgrade() { + post_install +} diff --git a/packaging/systemd/user/oratorsd.service b/packaging/systemd/user/oratorsd.service new file mode 100644 index 0000000..9997df2 --- /dev/null +++ b/packaging/systemd/user/oratorsd.service @@ -0,0 +1,12 @@ +[Unit] +Description=Orators Bluetooth speaker daemon +After=default.target bluetooth.target + +[Service] +Type=simple +ExecStart=/usr/bin/oratorsd +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=default.target diff --git a/scripts/aur/generate_srcinfo.sh b/scripts/aur/generate_srcinfo.sh new file mode 100755 index 0000000..585ccc5 --- /dev/null +++ b/scripts/aur/generate_srcinfo.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Usage: generate_srcinfo.sh " >&2 + exit 1 +fi + +pkgbuild=$(cd "$(dirname "$1")" && pwd)/$(basename "$1") +pkgdir=$(dirname "$pkgbuild") + +# shellcheck disable=SC1090 +source "$pkgbuild" + +pkgbase_value="${pkgbase:-$pkgname}" + +print_repeated() { + local key="$1" + shift + local value + for value in "$@"; do + printf '\t%s = %s\n' "$key" "$value" + done +} + +printf 'pkgbase = %s\n' "$pkgbase_value" +printf '\tpkgdesc = %s\n' "$pkgdesc" +printf '\tpkgver = %s\n' "$pkgver" +printf '\tpkgrel = %s\n' "$pkgrel" +printf '\turl = %s\n' "$url" +print_repeated 'arch' "${arch[@]}" +print_repeated 'license' "${license[@]}" +if declare -p makedepends >/dev/null 2>&1; then + print_repeated 'makedepends' "${makedepends[@]}" +fi +if declare -p depends >/dev/null 2>&1; then + print_repeated 'depends' "${depends[@]}" +fi +if declare -p provides >/dev/null 2>&1; then + print_repeated 'provides' "${provides[@]}" +fi +if declare -p conflicts >/dev/null 2>&1; then + print_repeated 'conflicts' "${conflicts[@]}" +fi +if [[ -n ${install:-} ]]; then + printf '\tinstall = %s\n' "$install" +fi +print_repeated 'source' "${source[@]}" +print_repeated 'sha256sums' "${sha256sums[@]}" +printf '\n' +printf 'pkgname = %s\n' "$pkgname" diff --git a/scripts/aur/publish_aur_package.sh b/scripts/aur/publish_aur_package.sh new file mode 100755 index 0000000..eab7ed6 --- /dev/null +++ b/scripts/aur/publish_aur_package.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOM' +Usage: publish_aur_package.sh +EOM +} + +if [[ $# -ne 3 ]]; then + usage >&2 + exit 1 +fi + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +package_name="$1" +source_dir=$(cd "$2" && pwd) +commit_message="$3" +packager_name="${AUR_PACKAGER_NAME:-Jonatan Jonasson}" +packager_email="${AUR_PACKAGER_EMAIL:-notes@madeingotland.com}" +repo_url="ssh://aur@aur.archlinux.org/${package_name}.git" +work_dir=$(mktemp -d) +repo_dir="$work_dir/$package_name" +trap 'rm -rf "$work_dir"' EXIT + +GIT_SSH_COMMAND=${GIT_SSH_COMMAND:-ssh} +export GIT_SSH_COMMAND + +if git ls-remote "$repo_url" >/dev/null 2>&1; then + git clone "$repo_url" "$repo_dir" +else + git init "$repo_dir" + git -C "$repo_dir" remote add origin "$repo_url" +fi + +rsync -a --delete --exclude '.git/' "$source_dir/" "$repo_dir/" +( + cd "$repo_dir" + "${repo_root}/scripts/aur/generate_srcinfo.sh" "$PWD/PKGBUILD" > .SRCINFO + git config user.name "$packager_name" + git config user.email "$packager_email" + git add -A + if git diff --cached --quiet; then + echo "No AUR changes to publish for ${package_name}." + exit 0 + fi + git commit -m "$commit_message" + git push origin HEAD:master +) diff --git a/scripts/aur/render_orators_bin_pkgbuild.py b/scripts/aur/render_orators_bin_pkgbuild.py new file mode 100755 index 0000000..7ec88d8 --- /dev/null +++ b/scripts/aur/render_orators_bin_pkgbuild.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from pathlib import Path +import shutil + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument('--version', required=True) + parser.add_argument('--source-url', required=True) + parser.add_argument('--sha256', required=True) + parser.add_argument('--output-dir', required=True) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + repo_root = Path(__file__).resolve().parents[2] + template_path = repo_root / 'packaging' / 'aur' / 'orators-bin' / 'PKGBUILD.in' + install_path = repo_root / 'packaging' / 'aur' / 'orators-bin' / 'orators.install' + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + rendered = template_path.read_text() + rendered = rendered.replace('@PKGVER@', args.version) + rendered = rendered.replace('@SOURCE_URL@', args.source_url) + rendered = rendered.replace('@SHA256@', args.sha256) + + (output_dir / 'PKGBUILD').write_text(rendered) + shutil.copy2(install_path, output_dir / 'orators.install') + + +if __name__ == '__main__': + main() diff --git a/scripts/release/build-release-archive.sh b/scripts/release/build-release-archive.sh new file mode 100755 index 0000000..384e483 --- /dev/null +++ b/scripts/release/build-release-archive.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOM' +Usage: build-release-archive.sh [--version ] [--target ] [--output-dir ] +EOM +} + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +version='' +target='x86_64-unknown-linux-gnu' +output_dir="$repo_root/dist" + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) + version="$2" + shift 2 + ;; + --target) + target="$2" + shift 2 + ;; + --output-dir) + output_dir="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ -z "$version" ]]; then + version=$(python - <<'PY' +import json, subprocess +metadata = json.loads(subprocess.check_output([ + 'cargo', 'metadata', '--no-deps', '--format-version', '1' +], text=True)) +for package in metadata['packages']: + if package['name'] == 'orators': + print(package['version']) + break +else: + raise SystemExit('could not resolve orators package version') +PY +) +fi + +archive_root="orators-v${version}-${target}" +archive_path="$output_dir/${archive_root}.tar.gz" +checksum_path="${archive_path}.sha256" +stage_dir=$(mktemp -d) +trap 'rm -rf "$stage_dir"' EXIT + +mkdir -p "$output_dir" +cd "$repo_root" + +cargo build --locked --release --target "$target" -p orators +build_dir="target/${target}/release" + +mkdir -p \ + "$stage_dir/$archive_root/bin" \ + "$stage_dir/$archive_root/systemd/user" + +install -Dm755 "$build_dir/orators" "$stage_dir/$archive_root/bin/orators" +install -Dm755 "$build_dir/oratorsctl" "$stage_dir/$archive_root/bin/oratorsctl" +install -Dm755 "$build_dir/oratorsd" "$stage_dir/$archive_root/bin/oratorsd" +install -Dm644 packaging/systemd/user/oratorsd.service \ + "$stage_dir/$archive_root/systemd/user/oratorsd.service" +install -Dm644 README.md "$stage_dir/$archive_root/README.md" +install -Dm644 LICENSE "$stage_dir/$archive_root/LICENSE" + +tar -C "$stage_dir" -czf "$archive_path" "$archive_root" +archive_name=$(basename "$archive_path") +checksum_name=$(basename "$checksum_path") +( + cd "$output_dir" + sha256sum "$archive_name" > "$checksum_name" +) +cat "$checksum_path" >&2 + +echo "$archive_path" diff --git a/scripts/validate_packaging.sh b/scripts/validate_packaging.sh new file mode 100755 index 0000000..2b0b239 --- /dev/null +++ b/scripts/validate_packaging.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +work_dir=$(mktemp -d) +trap 'rm -rf "$work_dir"' EXIT + +cd "$repo_root" + +bash -n scripts/release/build-release-archive.sh +bash -n scripts/aur/publish_aur_package.sh +bash -n scripts/aur/generate_srcinfo.sh +python - <<'PY' +from pathlib import Path +source = Path('scripts/aur/render_orators_bin_pkgbuild.py').read_text() +compile(source, 'scripts/aur/render_orators_bin_pkgbuild.py', 'exec') +PY + +archive_path=$(./scripts/release/build-release-archive.sh --output-dir "$work_dir/dist") +archive_name=$(basename "$archive_path") +test -f "$archive_path" +test -f "${archive_path}.sha256" +grep -F -- "$archive_name" "${archive_path}.sha256" +if grep -F -- "$archive_path" "${archive_path}.sha256" >/dev/null; then + echo "checksum file should not contain absolute archive paths" >&2 + exit 1 +fi + +./scripts/aur/render_orators_bin_pkgbuild.py \ + --version 0.1.0 \ + --source-url https://example.invalid/orators-v0.1.0-x86_64-unknown-linux-gnu.tar.gz \ + --sha256 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef \ + --output-dir "$work_dir/orators-bin" +./scripts/aur/generate_srcinfo.sh "$work_dir/orators-bin/PKGBUILD" > "$work_dir/orators-bin/.SRCINFO" +./scripts/aur/generate_srcinfo.sh packaging/aur/orators-git/PKGBUILD > "$work_dir/orators-git.SRCINFO" + +test -s "$work_dir/orators-bin/.SRCINFO" +test -s "$work_dir/orators-git.SRCINFO" + +fake_cargo_dir="$work_dir/fake-cargo" +fake_cargo_log="$work_dir/fake-cargo.log" +fake_target='aarch64-unknown-linux-gnu' +mkdir -p "$fake_cargo_dir" +cat > "$fake_cargo_dir/cargo" <<'EOF_CARGO' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" == metadata ]]; then + cat <<'JSON' +{"packages":[{"name":"orators","version":"0.1.0"}]} +JSON + exit 0 +fi +if [[ "$1" == build ]]; then + printf '%s\n' "$*" > "$FAKE_CARGO_LOG" + build_dir="$PWD/target/$FAKE_CARGO_TARGET/release" + mkdir -p "$build_dir" + for bin in orators oratorsctl oratorsd; do + printf '#!/usr/bin/env bash\nexit 0\n' > "$build_dir/$bin" + chmod +x "$build_dir/$bin" + done + exit 0 +fi +echo "unexpected cargo invocation: $*" >&2 +exit 1 +EOF_CARGO +chmod +x "$fake_cargo_dir/cargo" +FAKE_CARGO_LOG="$fake_cargo_log" \ +FAKE_CARGO_TARGET="$fake_target" \ +PATH="$fake_cargo_dir:$PATH" \ +./scripts/release/build-release-archive.sh \ + --version 0.1.0 \ + --target "$fake_target" \ + --output-dir "$work_dir/fake-dist" >/dev/null + +grep -F -- '--target' "$fake_cargo_log" +grep -F -- "$fake_target" "$fake_cargo_log" +grep -F -- "orators-v0.1.0-${fake_target}.tar.gz" "$work_dir/fake-dist/orators-v0.1.0-${fake_target}.tar.gz.sha256" +if grep -F -- "$work_dir/fake-dist/orators-v0.1.0-${fake_target}.tar.gz" "$work_dir/fake-dist/orators-v0.1.0-${fake_target}.tar.gz.sha256" >/dev/null; then + echo "fake-target checksum file should not contain absolute archive paths" >&2 + exit 1 +fi +tar -tzf "$work_dir/fake-dist/orators-v0.1.0-${fake_target}.tar.gz" | grep -F -- "orators-v0.1.0-${fake_target}/bin/orators"