Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ jobs:
- name: Generated TypeScript bindings are up to date
run: git diff --exit-code -- app/src/lib/protocol.gen.ts

# The daemon and its crates off Windows (spec 18, item 8). Plain `cargo`
# builds the default members only: the app crates need the desktop
# libraries of each system and join later.
# The daemon, its crates and the app's Rust side off Windows (spec 18,
# item 8). Plain `cargo` builds the default members; the app crate comes
# after, with the desktop libraries Tauri needs on Linux. The setup
# window is Windows only.
rust-unix:
name: Rust (${{ matrix.name }})
strategy:
Expand All @@ -52,6 +53,16 @@ jobs:
components: clippy
- uses: Swatinem/rust-cache@v2
- run: cargo clippy --all-targets -- -D warnings
- name: Desktop libraries for the app (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev librsvg2-dev libxdo-dev
- name: The app's Rust side
run: |
cargo clippy -p botloft-app --all-targets -- -D warnings
cargo test -p botloft-app
# The runner image ships Edge and Chrome with sandbox helpers that are
# not setuid root, as their packages would leave them, so they refuse
# to start. The browser tests need a working sandbox.
Expand Down
37 changes: 22 additions & 15 deletions app/src-tauri/src/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//! because a window started by the foreground app comes to the front.

use std::path::Path;
#[cfg(windows)]
use std::process::Command;

/// Whether `path` is a Claude Code executable the app may run: the daemon
Expand All @@ -21,32 +22,38 @@ fn is_claude(path: &Path) -> bool {
&& path.is_file()
}

/// Runs `claude auth login` in a new console window and waits for it.
/// Returns whether it signed in (exit code 0).
/// Runs `claude auth login` in a new console window (a terminal window off
/// Windows) and waits for it. Returns whether it signed in (exit code 0).
pub fn sign_in(path: &Path) -> Result<bool, String> {
if !is_claude(path) {
return Err(format!("{} is not Claude Code", path.display()));
}
let mut command = Command::new(path);
command.args(["auth", "login"]);
// An app started from inside a Claude Code session would pass its
// session variables on, and the login would think it runs inside it.
for (name, _) in std::env::vars_os() {
let name = name.to_string_lossy();
if name == "CLAUDECODE" || name.starts_with("CLAUDE_CODE_") {
command.env_remove(name.as_ref());
}
}
let session: Vec<String> = std::env::vars_os()
.map(|(name, _)| name.to_string_lossy().into_owned())
.filter(|name| name == "CLAUDECODE" || name.starts_with("CLAUDE_CODE_"))
.collect();
#[cfg(windows)]
{
use std::os::windows::process::CommandExt as _;
const CREATE_NEW_CONSOLE: u32 = 0x0000_0010;
command.creation_flags(CREATE_NEW_CONSOLE);
let mut command = Command::new(path);
command
.args(["auth", "login"])
.creation_flags(CREATE_NEW_CONSOLE);
for name in &session {
command.env_remove(name);
}
let status = command
.status()
.map_err(|err| format!("cannot run {}: {err}", path.display()))?;
Ok(status.success())
}
#[cfg(unix)]
{
crate::terminal::run(path, &["auth", "login"], &session)
}
let status = command
.status()
.map_err(|err| format!("cannot run {}: {err}", path.display()))?;
Ok(status.success())
}

#[cfg(test)]
Expand Down
41 changes: 12 additions & 29 deletions app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@

mod claude;
mod daemon;
mod open;
mod sign_in;
#[cfg(unix)]
mod terminal;
mod window;

use std::path::Path;
Expand Down Expand Up @@ -58,24 +61,21 @@ async fn read_owner_token() -> Result<String, String> {
blocking(|| daemon::owner_token(&daemon::endpoint()?)).await
}

/// Opens a folder in Explorer. Files are refused: Explorer would run them.
/// Opens a folder in the file manager. Files are refused: opening one
/// could run it.
#[tauri::command]
async fn open_path(path: String) -> Result<(), String> {
blocking(move || {
let folder = Path::new(&path);
if !folder.is_absolute() || !folder.is_dir() {
return Err(format!("{path} is not a folder"));
}
std::process::Command::new("explorer.exe")
.arg(folder)
.spawn()
.map(drop)
.map_err(|err| format!("cannot open Explorer: {err}"))
open::folder(folder)
})
.await
}

/// Extensions `open_file` will open with the program Windows picks. Anything
/// Extensions `open_file` will open with the program the system picks. Anything
/// else could be a program or a script that runs when opened.
const OPENABLE: [&str; 24] = [
"pdf", "png", "jpg", "jpeg", "gif", "webp", "svg", "txt", "md", "csv", "json", "log", "html",
Expand All @@ -88,7 +88,7 @@ fn is_openable(path: &Path) -> bool {
.is_some_and(|extension| OPENABLE.contains(&extension.to_ascii_lowercase().as_str()))
}

/// Opens a file a bot made with the program Windows uses for it. Only
/// Opens a file a bot made with the program the system uses for it. Only
/// documents, images, sound and video: never something that runs.
#[tauri::command]
async fn open_file(path: String) -> Result<(), String> {
Expand All @@ -100,30 +100,20 @@ async fn open_file(path: String) -> Result<(), String> {
if !is_openable(file) {
return Err("this kind of file is not opened from Botloft".into());
}
std::process::Command::new("explorer.exe")
.arg(file)
.spawn()
.map(drop)
.map_err(|err| format!("cannot open the file: {err}"))
open::file(file)
})
.await
}

/// Shows a file in its folder, selected. Nothing is run.
#[tauri::command]
async fn reveal_file(path: String) -> Result<(), String> {
use std::os::windows::process::CommandExt as _;
blocking(move || {
let file = Path::new(&path);
if !file.is_absolute() || !file.exists() || path.contains('"') {
if !file.is_absolute() || !file.exists() {
return Err(format!("{path} is not a file"));
}
// Explorer reads `/select,` and the path as one argument.
std::process::Command::new("explorer.exe")
.raw_arg(format!("/select,\"{path}\""))
.spawn()
.map(drop)
.map_err(|err| format!("cannot open Explorer: {err}"))
open::reveal(file)
})
.await
}
Expand Down Expand Up @@ -159,14 +149,7 @@ async fn open_url(url: String) -> Result<(), String> {
if !is_web_link(&url) {
return Err("only web links can be opened".into());
}
blocking(move || {
std::process::Command::new("explorer.exe")
.arg(&url)
.spawn()
.map(drop)
.map_err(|err| format!("cannot open the browser: {err}"))
})
.await
blocking(move || open::url(&url)).await
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
Expand Down
68 changes: 68 additions & 0 deletions app/src-tauri/src/open.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//! Handing a folder, a file or a web link to the system (spec 15.2):
//! Explorer on Windows, `open` on macOS, `xdg-open` on Linux. The callers
//! check what may be opened; nothing here runs a program the owner did not
//! pick.

use std::path::Path;
use std::process::Command;

#[cfg(windows)]
const LAUNCHER: &str = "explorer.exe";
#[cfg(target_os = "macos")]
const LAUNCHER: &str = "open";
#[cfg(all(unix, not(target_os = "macos")))]
const LAUNCHER: &str = "xdg-open";

fn spawn(command: &mut Command) -> Result<(), String> {
command
.spawn()
.map(drop)
.map_err(|err| format!("cannot run {LAUNCHER}: {err}"))
}

/// A folder in the file manager. On macOS an app bundle is a folder too,
/// and `open` would start it.
pub fn folder(path: &Path) -> Result<(), String> {
if cfg!(target_os = "macos")
&& path
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("app"))
{
return Err(format!("{} is an app, not a folder", path.display()));
}
spawn(Command::new(LAUNCHER).arg(path))
}

/// A document with the program the system uses for it.
pub fn file(path: &Path) -> Result<(), String> {
spawn(Command::new(LAUNCHER).arg(path))
}

/// A web link in the default browser.
pub fn url(url: &str) -> Result<(), String> {
spawn(Command::new(LAUNCHER).arg(url))
}

/// A file shown selected in its folder. Linux has no common way to select
/// it, so its folder opens.
pub fn reveal(path: &Path) -> Result<(), String> {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt as _;
let text = path.to_string_lossy();
if text.contains('"') {
return Err(format!("{text} is not a file"));
}
// Explorer reads `/select,` and the path as one argument.
spawn(Command::new(LAUNCHER).raw_arg(format!("/select,\"{text}\"")))
}
#[cfg(target_os = "macos")]
{
spawn(Command::new(LAUNCHER).arg("-R").arg(path))
}
#[cfg(all(unix, not(target_os = "macos")))]
{
let folder = path.parent().unwrap_or(path);
spawn(Command::new(LAUNCHER).arg(folder))
}
}
Loading
Loading