mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-27 00:53:37 +00:00
Added a runtime endpoint which opens a document at a page
This commit is contained in:
parent
4ef6753707
commit
ff5da5abc7
@ -1,11 +1,13 @@
|
||||
use log::{error, info};
|
||||
use log::{error, info, warn};
|
||||
use axum::extract::Query;
|
||||
use axum::Json;
|
||||
use file_format::FileFormat;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri_plugin_dialog::{DialogExt, FileDialogBuilder};
|
||||
use crate::api_token::APIToken;
|
||||
use crate::app_window::MAIN_WINDOW;
|
||||
use crate::file_data::is_executable_content;
|
||||
|
||||
#[cfg(any(windows, target_os = "macos"))]
|
||||
use std::process::Command;
|
||||
@ -55,6 +57,14 @@ pub struct OpenPathOptions {
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct OpenDocumentOptions {
|
||||
path: String,
|
||||
|
||||
/// The page to show, counted from one, or `None` when the document has no page to show.
|
||||
page: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DirectorySelectionResponse {
|
||||
user_cancelled: bool,
|
||||
@ -85,6 +95,20 @@ pub struct OpenPathResponse {
|
||||
issue: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct OpenDocumentResponse {
|
||||
success: bool,
|
||||
|
||||
/// Whether the document was handed to a program together with the page it should show.
|
||||
///
|
||||
/// False means the document opens on its first page: no page was asked for, the system uses a
|
||||
/// program we cannot tell a page, or the attempt to start that program failed. None of these
|
||||
/// is an error — the document opens either way — so the app only notes it in its log.
|
||||
page_applied: bool,
|
||||
|
||||
issue: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct PreviousFile {
|
||||
file_path: String,
|
||||
@ -386,6 +410,418 @@ async fn open_file_manager_target(requested_path: &Path) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a document in the program the system uses for it, on the given page where that is possible.
|
||||
///
|
||||
/// The page is best effort and never decides whether this succeeded: a viewer which cannot be told
|
||||
/// a page still shows the document, which is what the user asked for by clicking a source.
|
||||
pub async fn open_document(
|
||||
_token: APIToken,
|
||||
payload: Json<OpenDocumentOptions>,
|
||||
) -> Json<OpenDocumentResponse> {
|
||||
let requested_path = PathBuf::from(payload.path.trim());
|
||||
if let Some(issue) = refuse_document(&requested_path) {
|
||||
error!(Source = "Tauri"; "Refused to open a document: {issue}");
|
||||
return Json(OpenDocumentResponse {
|
||||
success: false,
|
||||
page_applied: false,
|
||||
issue,
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// A page of zero is how a caller says it has none: a slide and a spreadsheet row are not
|
||||
// pages, and neither is a passage whose page the index never learned.
|
||||
//
|
||||
let page = payload.page.filter(|page| *page > 0);
|
||||
if let Some(page) = page && try_open_at_page(&requested_path, page).await {
|
||||
info!("Opened document at page {page}: {requested_path:?}");
|
||||
return Json(OpenDocumentResponse {
|
||||
success: true,
|
||||
page_applied: true,
|
||||
issue: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
match tauri_plugin_opener::open_path(&requested_path, None::<&str>) {
|
||||
Ok(()) => {
|
||||
info!("Opened document: {requested_path:?}");
|
||||
Json(OpenDocumentResponse {
|
||||
success: true,
|
||||
page_applied: false,
|
||||
issue: String::new(),
|
||||
})
|
||||
},
|
||||
|
||||
Err(error) => {
|
||||
let issue = format!("Failed to open the document: {error}");
|
||||
error!(Source = "Tauri"; "{issue}");
|
||||
Json(OpenDocumentResponse {
|
||||
success: false,
|
||||
page_applied: false,
|
||||
issue,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Extensions which start something instead of being something.
|
||||
///
|
||||
/// Such a file gives nothing away by its content — a `.desktop` entry and a `.cmd` script are
|
||||
/// plain text, a `.lnk` is a shortcut — so its name is the only thing left to recognize it by.
|
||||
const LAUNCHER_EXTENSIONS: [&str; 10] = [
|
||||
"desktop", "command", "lnk", "url", "bat", "cmd", "ps1", "vbs", "scpt", "app",
|
||||
];
|
||||
|
||||
/// Says why a document must not be opened, or `None` when it may be.
|
||||
///
|
||||
/// The path arrives from a data source: a folder the user pointed us at, or an ERI server which is
|
||||
/// free to name any file it likes. This endpoint hands a file to whatever the system has registered
|
||||
/// for it, so the line worth drawing is that a document is opened and a program is never started.
|
||||
/// It is drawn here because this is the one place every caller passes through.
|
||||
fn refuse_document(requested_path: &Path) -> Option<String> {
|
||||
if requested_path.as_os_str().is_empty() {
|
||||
return Some(String::from("The path is empty."));
|
||||
}
|
||||
|
||||
if !requested_path.is_file() {
|
||||
return Some(format!("The path is not a file: {}", requested_path.to_string_lossy()));
|
||||
}
|
||||
|
||||
let extension = requested_path.extension()
|
||||
.map(|extension| extension.to_string_lossy().to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
|
||||
if LAUNCHER_EXTENSIONS.contains(&extension.as_str()) {
|
||||
return Some(format!(
|
||||
"A file of type '{extension}' starts a program instead of showing a document and is not opened: {}",
|
||||
requested_path.to_string_lossy(),
|
||||
));
|
||||
}
|
||||
|
||||
match FileFormat::from_file(requested_path) {
|
||||
Ok(format) if is_executable_content(format) => Some(format!(
|
||||
"The file is a program, not a document, and is not opened: {}",
|
||||
requested_path.to_string_lossy(),
|
||||
)),
|
||||
|
||||
//
|
||||
// A file whose content we cannot place is not a file we refuse. The format is asked in
|
||||
// order to catch a program carrying a harmless extension, nothing else; what the system
|
||||
// makes of anything else is the system's decision, as it is for every other file.
|
||||
//
|
||||
Ok(_) => None,
|
||||
|
||||
Err(error) => {
|
||||
warn!(Source = "Tauri"; "Could not identify the content of '{}': {error}", requested_path.to_string_lossy());
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Tries to show the document on the given page, and says whether it did.
|
||||
///
|
||||
/// On macOS this always answers `false`: `open` drops the fragment of a URL before the program it
|
||||
/// starts ever sees it, with and without `-a`, so a page cannot be named from the command line.
|
||||
/// The document opens on its first page there, and the source names the page for the reader.
|
||||
async fn try_open_at_page(path: &Path, page: u32) -> bool {
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
{
|
||||
let DocumentOpenPlan::WithPage { program, arguments } = resolve_document_open_plan(path, page).await else {
|
||||
return false;
|
||||
};
|
||||
|
||||
return match start_page_aware_viewer(&program, &arguments) {
|
||||
Ok(()) => true,
|
||||
|
||||
//
|
||||
// Failing to start the viewer ourselves is not something the user has to hear about:
|
||||
// the caller opens the document plainly afterwards, only without the page.
|
||||
//
|
||||
Err(issue) => {
|
||||
warn!(Source = "Tauri"; "Could not open '{}' at page {page}, opening it without a page instead: {issue}", path.to_string_lossy());
|
||||
false
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = (path, page);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// How a document viewer wants to be told which page to show.
|
||||
///
|
||||
/// They all mean the same thing and every one of them spells it differently. A viewer which is not
|
||||
/// covered here shows its first page, which is what the system would have done anyway.
|
||||
#[cfg(any(windows, target_os = "linux", test))]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum PageArgument {
|
||||
/// The page travels in the URL fragment, the way the PDF Open Parameters define it. Browsers
|
||||
/// read it, and on Windows a browser is what most people open a PDF with.
|
||||
UrlFragment,
|
||||
|
||||
/// Acrobat and Acrobat Reader take an open action: `/A page=12`.
|
||||
AcrobatOpenAction,
|
||||
|
||||
/// The GNOME document viewer and its forks count from zero, so page 12 is index 11.
|
||||
ZeroBasedIndex,
|
||||
|
||||
/// Okular takes `-p 12`.
|
||||
OkularPage,
|
||||
|
||||
/// Zathura takes `-P 12`.
|
||||
ZathuraPage,
|
||||
}
|
||||
|
||||
/// What it takes to show a document on a page.
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum DocumentOpenPlan {
|
||||
/// Hand the file to the system and let it decide. The document opens on its first page.
|
||||
Plain,
|
||||
|
||||
/// Start this program ourselves, because it takes the page as an argument.
|
||||
WithPage { program: String, arguments: Vec<String> },
|
||||
}
|
||||
|
||||
/// Whether this file is a PDF.
|
||||
///
|
||||
/// Only PDFs are sent to a page: the handler is looked up for PDFs, and the arguments below are
|
||||
/// the ones PDF viewers understand. A Word file has a page too, but the programs which show one
|
||||
/// cannot be told to go there.
|
||||
#[cfg(any(windows, target_os = "linux", test))]
|
||||
fn is_pdf_document(path: &Path) -> bool {
|
||||
path.extension().is_some_and(|extension| extension.eq_ignore_ascii_case("pdf"))
|
||||
}
|
||||
|
||||
/// Builds the arguments which name the page, in the spelling this viewer expects.
|
||||
#[cfg(any(windows, target_os = "linux", test))]
|
||||
fn page_arguments(argument: PageArgument, path: &Path, page: u32) -> Option<Vec<String>> {
|
||||
let path_argument = path.to_string_lossy().to_string();
|
||||
Some(match argument {
|
||||
PageArgument::UrlFragment => vec![document_url_with_page(path, page)?],
|
||||
PageArgument::AcrobatOpenAction => vec![String::from("/A"), format!("page={page}"), path_argument],
|
||||
PageArgument::ZeroBasedIndex => vec![format!("--page-index={}", page.saturating_sub(1)), path_argument],
|
||||
PageArgument::OkularPage => vec![String::from("-p"), page.to_string(), path_argument],
|
||||
PageArgument::ZathuraPage => vec![String::from("-P"), page.to_string(), path_argument],
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds a `file:` URL which names the page, the way the PDF Open Parameters define it.
|
||||
///
|
||||
/// The URL is built instead of written by hand because a path may hold spaces, umlauts or a hash
|
||||
/// of its own, and writing one by hand turns those into a different path or into a second fragment.
|
||||
#[cfg(any(windows, target_os = "linux", test))]
|
||||
fn document_url_with_page(path: &Path, page: u32) -> Option<String> {
|
||||
let mut url = tauri::Url::from_file_path(path).ok()?;
|
||||
url.set_fragment(Some(&format!("page={page}")));
|
||||
Some(url.to_string())
|
||||
}
|
||||
|
||||
/// Starts the viewer. Success means the program was started, not that it showed the page.
|
||||
///
|
||||
/// Waiting for it to say so is not possible: a viewer runs until the user closes it, so waiting
|
||||
/// would hold the request open for as long as the document stays on screen.
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
fn start_page_aware_viewer(program: &str, arguments: &[String]) -> Result<(), String> {
|
||||
let mut command = std::process::Command::new(program);
|
||||
command.args(arguments);
|
||||
|
||||
#[cfg(windows)]
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
|
||||
command.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|error| format!("Failed to start '{program}': {error}"))
|
||||
}
|
||||
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
async fn resolve_document_open_plan(path: &Path, page: u32) -> DocumentOpenPlan {
|
||||
if !is_pdf_document(path) {
|
||||
return DocumentOpenPlan::Plain;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let Some(prog_id) = windows_default_pdf_prog_id() else {
|
||||
return DocumentOpenPlan::Plain;
|
||||
};
|
||||
|
||||
let Some(argument) = windows_page_argument(&prog_id) else {
|
||||
return DocumentOpenPlan::Plain;
|
||||
};
|
||||
|
||||
let Some(program) = windows_handler_executable(&prog_id) else {
|
||||
return DocumentOpenPlan::Plain;
|
||||
};
|
||||
|
||||
let Some(arguments) = page_arguments(argument, path, page) else {
|
||||
return DocumentOpenPlan::Plain;
|
||||
};
|
||||
|
||||
DocumentOpenPlan::WithPage { program, arguments }
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let Some(desktop_id) = linux_default_pdf_handler().await else {
|
||||
return DocumentOpenPlan::Plain;
|
||||
};
|
||||
|
||||
let Some((program, argument)) = linux_page_aware_program(&desktop_id) else {
|
||||
return DocumentOpenPlan::Plain;
|
||||
};
|
||||
|
||||
let Some(arguments) = page_arguments(argument, path, page) else {
|
||||
return DocumentOpenPlan::Plain;
|
||||
};
|
||||
|
||||
DocumentOpenPlan::WithPage { program, arguments }
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads which program the user opens PDFs with.
|
||||
///
|
||||
/// The user's own choice comes first; the class registration is what is left when they never made
|
||||
/// one, for instance right after the system was installed.
|
||||
#[cfg(windows)]
|
||||
fn windows_default_pdf_prog_id() -> Option<String> {
|
||||
use windows_registry::*;
|
||||
|
||||
const USER_CHOICE_KEY: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.pdf\UserChoice";
|
||||
|
||||
if let Ok(key) = CURRENT_USER.open(USER_CHOICE_KEY) && let Ok(prog_id) = key.get_string("ProgId") {
|
||||
return Some(prog_id);
|
||||
}
|
||||
|
||||
CLASSES_ROOT.open(".pdf").ok()
|
||||
.and_then(|key| key.get_string("").ok())
|
||||
.filter(|prog_id| !prog_id.is_empty())
|
||||
}
|
||||
|
||||
/// Reads the program behind a registered file type.
|
||||
#[cfg(windows)]
|
||||
fn windows_handler_executable(prog_id: &str) -> Option<String> {
|
||||
use windows_registry::*;
|
||||
|
||||
let command = CLASSES_ROOT.open(format!(r"{prog_id}\shell\open\command")).ok()?
|
||||
.get_string("").ok()?;
|
||||
|
||||
executable_from_command(&command)
|
||||
}
|
||||
|
||||
/// Picks the program out of a registry open command such as
|
||||
/// `"C:\Program Files\...\msedge.exe" --single-argument %1`.
|
||||
///
|
||||
/// The arguments the command carries are dropped on purpose: they are written for a file name, and
|
||||
/// what follows is a URL naming a page instead.
|
||||
#[cfg(any(windows, test))]
|
||||
fn executable_from_command(command: &str) -> Option<String> {
|
||||
let command = command.trim();
|
||||
let executable = match command.strip_prefix('"') {
|
||||
Some(quoted) => quoted.split('"').next()?,
|
||||
None => command.split_whitespace().next()?,
|
||||
};
|
||||
|
||||
let executable = executable.trim();
|
||||
if executable.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(String::from(executable))
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps the registered file type onto the way its program wants to hear about a page.
|
||||
#[cfg(any(windows, test))]
|
||||
fn windows_page_argument(prog_id: &str) -> Option<PageArgument> {
|
||||
let prog_id = prog_id.to_ascii_lowercase();
|
||||
|
||||
//
|
||||
// Acrobat is asked about first, because its registration says nothing about a browser while
|
||||
// the browsers below are recognized by their own name in it.
|
||||
//
|
||||
if prog_id.contains("acroexch") || prog_id.contains("acrobat") {
|
||||
return Some(PageArgument::AcrobatOpenAction);
|
||||
}
|
||||
|
||||
const BROWSERS: [&str; 5] = ["msedge", "chrome", "firefox", "opera", "brave"];
|
||||
if BROWSERS.iter().any(|browser| prog_id.contains(browser)) {
|
||||
return Some(PageArgument::UrlFragment);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Reads which program the desktop opens PDFs with.
|
||||
///
|
||||
/// Inside a Flatpak there is nothing to read: the sandbox has its own list of registered programs
|
||||
/// rather than the desktop's, and even the right answer would name a program which is not in the
|
||||
/// sandbox to be started. The document is handed to the desktop portal instead, which opens it on
|
||||
/// its first page.
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn linux_default_pdf_handler() -> Option<String> {
|
||||
if crate::environment::is_flatpak() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let output = tokio::process::Command::new("xdg-mime")
|
||||
.args(["query", "default", "application/pdf"])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
//
|
||||
// More than one entry can be registered, and the first one is the one the desktop uses.
|
||||
//
|
||||
let desktop_id = String::from_utf8_lossy(&output.stdout).lines().next()?.trim().to_string();
|
||||
if desktop_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(desktop_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a desktop entry onto the program behind it and the way that program wants to hear about a page.
|
||||
///
|
||||
/// A desktop id is not the name of a binary — GNOME's viewer answers `org.gnome.Evince.desktop` —
|
||||
/// so reading the desktop file would be the thorough way to find the program. Recognizing the few
|
||||
/// viewers which can be sent to a page at all is the short one, and everything else opens the way
|
||||
/// it always did, through the desktop's own handler.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn linux_page_aware_program(desktop_id: &str) -> Option<(String, PageArgument)> {
|
||||
const KNOWN_VIEWERS: [(&str, &str, PageArgument); 9] = [
|
||||
//
|
||||
// Atril and Xreader are forks of Evince and count their pages from zero just as it does.
|
||||
//
|
||||
("evince", "evince", PageArgument::ZeroBasedIndex),
|
||||
("atril", "atril", PageArgument::ZeroBasedIndex),
|
||||
("xreader", "xreader", PageArgument::ZeroBasedIndex),
|
||||
|
||||
("okular", "okular", PageArgument::OkularPage),
|
||||
("zathura", "zathura", PageArgument::ZathuraPage),
|
||||
|
||||
//
|
||||
// Chrome is asked about before Chromium, so that a desktop entry naming both lands on the
|
||||
// program the user actually installed.
|
||||
//
|
||||
("google-chrome", "google-chrome", PageArgument::UrlFragment),
|
||||
("chromium", "chromium", PageArgument::UrlFragment),
|
||||
("microsoft-edge", "microsoft-edge", PageArgument::UrlFragment),
|
||||
("firefox", "firefox", PageArgument::UrlFragment),
|
||||
];
|
||||
|
||||
let desktop_id = desktop_id.to_ascii_lowercase();
|
||||
KNOWN_VIEWERS.iter()
|
||||
.find(|(needle, _, _)| desktop_id.contains(needle))
|
||||
.map(|(_, program, argument)| (String::from(*program), *argument))
|
||||
}
|
||||
|
||||
/// Applies an optional file type filter to a FileDialogBuilder.
|
||||
fn apply_filter<R: tauri::Runtime>(file_dialog: FileDialogBuilder<R>, filter: &Option<FileTypeFilter>) -> FileDialogBuilder<R> {
|
||||
match filter {
|
||||
@ -625,4 +1061,153 @@ mod tests {
|
||||
|
||||
assert!(resolve_file_manager_target(&invalid_path).is_none());
|
||||
}
|
||||
|
||||
/// The bytes an ELF binary starts with. A file which begins like this is a program, whatever
|
||||
/// its name promises.
|
||||
const ELF_HEADER: &[u8] = b"\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x3e\x00";
|
||||
|
||||
#[test]
|
||||
fn a_document_may_be_opened() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let document_path = temp_dir.path().join("handbook.pdf");
|
||||
fs::write(&document_path, b"%PDF-1.7\n% a handbook\n").unwrap();
|
||||
|
||||
assert_eq!(refuse_document(&document_path), None);
|
||||
}
|
||||
|
||||
/// A program which carries a harmless extension is the case this guard exists for: nothing
|
||||
/// about the name says what it is, so the content has to.
|
||||
#[test]
|
||||
fn a_program_named_like_a_document_is_refused() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let disguised_path = temp_dir.path().join("handbook.pdf");
|
||||
fs::write(&disguised_path, ELF_HEADER).unwrap();
|
||||
|
||||
let refusal = refuse_document(&disguised_path).unwrap();
|
||||
|
||||
assert!(refusal.contains("is a program"), "The refusal says why: {refusal}");
|
||||
}
|
||||
|
||||
/// The other way round: a launcher is plain text and gives nothing away, so it is refused by
|
||||
/// its name.
|
||||
#[test]
|
||||
fn a_launcher_is_refused_although_it_reads_like_text() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let launcher_path = temp_dir.path().join("handbook.desktop");
|
||||
fs::write(&launcher_path, "[Desktop Entry]\nExec=rm -rf ~\n").unwrap();
|
||||
|
||||
let refusal = refuse_document(&launcher_path).unwrap();
|
||||
|
||||
assert!(refusal.contains("starts a program"), "The refusal says why: {refusal}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_launcher_is_refused_whatever_its_extension_is_spelled_like() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let launcher_path = temp_dir.path().join("handbook.CMD");
|
||||
fs::write(&launcher_path, "echo nothing to see here\n").unwrap();
|
||||
|
||||
assert!(refuse_document(&launcher_path).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_which_is_no_file_is_refused() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
assert!(refuse_document(&temp_dir.path().join("missing.pdf")).is_some(), "A file which is not there cannot be opened.");
|
||||
assert!(refuse_document(temp_dir.path()).is_some(), "A folder is not a document.");
|
||||
assert!(refuse_document(Path::new("")).is_some(), "An empty path names nothing.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_pdf_is_sent_to_a_page() {
|
||||
assert!(is_pdf_document(Path::new("/docs/handbook.pdf")));
|
||||
assert!(is_pdf_document(Path::new("/docs/handbook.PDF")), "How the extension is spelled says nothing about the file.");
|
||||
assert!(!is_pdf_document(Path::new("/docs/handbook.docx")), "A Word file has pages, but no program which shows one can be told to go there.");
|
||||
assert!(!is_pdf_document(Path::new("/docs/handbook")));
|
||||
}
|
||||
|
||||
/// Writing the URL by hand would leave the space in the name as it is, and the browser would
|
||||
/// look for a file whose name ends before it.
|
||||
#[test]
|
||||
fn a_browser_is_told_the_page_in_the_url() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let document_path = temp_dir.path().join("Größere Übersicht.pdf");
|
||||
|
||||
let arguments = page_arguments(PageArgument::UrlFragment, &document_path, 12).unwrap();
|
||||
|
||||
assert_eq!(arguments.len(), 1, "A browser takes the document and the page as one URL.");
|
||||
|
||||
let url = tauri::Url::parse(&arguments[0]).unwrap();
|
||||
assert_eq!(url.fragment(), Some("page=12"), "The page travels in the fragment, the way the PDF Open Parameters define it.");
|
||||
assert_eq!(url.to_file_path().unwrap(), document_path, "A name with spaces and umlauts still names the same file.");
|
||||
}
|
||||
|
||||
/// Everybody means page twelve, and everybody says it differently.
|
||||
#[test]
|
||||
fn every_viewer_spells_the_page_its_own_way() {
|
||||
let document = Path::new("/docs/handbook.pdf");
|
||||
|
||||
assert_eq!(
|
||||
page_arguments(PageArgument::AcrobatOpenAction, document, 12).unwrap(),
|
||||
vec![String::from("/A"), String::from("page=12"), String::from("/docs/handbook.pdf")],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
page_arguments(PageArgument::ZeroBasedIndex, document, 12).unwrap(),
|
||||
vec![String::from("--page-index=11"), String::from("/docs/handbook.pdf")],
|
||||
"The GNOME viewer counts from zero, so page twelve is index eleven.",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
page_arguments(PageArgument::OkularPage, document, 12).unwrap(),
|
||||
vec![String::from("-p"), String::from("12"), String::from("/docs/handbook.pdf")],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
page_arguments(PageArgument::ZathuraPage, document, 12).unwrap(),
|
||||
vec![String::from("-P"), String::from("12"), String::from("/docs/handbook.pdf")],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_recognizes_the_programs_it_can_send_to_a_page() {
|
||||
assert_eq!(windows_page_argument("AcroExch.Document.DC"), Some(PageArgument::AcrobatOpenAction));
|
||||
assert_eq!(windows_page_argument("MSEdgePDF"), Some(PageArgument::UrlFragment));
|
||||
assert_eq!(windows_page_argument("ChromePDF"), Some(PageArgument::UrlFragment));
|
||||
assert_eq!(windows_page_argument("FirefoxPDF"), Some(PageArgument::UrlFragment));
|
||||
assert_eq!(windows_page_argument("Applications\\SumatraPDF.exe"), None, "A viewer we know nothing about opens its first page.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_program_is_read_out_of_the_registered_command() {
|
||||
assert_eq!(
|
||||
executable_from_command(r#""C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" --single-argument %1"#).as_deref(),
|
||||
Some(r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"),
|
||||
"A quoted program keeps the spaces in its path and loses the arguments written for a file name.",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
executable_from_command(r#"C:\Windows\System32\viewer.exe "%1""#).as_deref(),
|
||||
Some(r"C:\Windows\System32\viewer.exe"),
|
||||
);
|
||||
|
||||
assert_eq!(executable_from_command(" "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_recognizes_the_programs_it_can_send_to_a_page() {
|
||||
assert_eq!(
|
||||
linux_page_aware_program("org.gnome.Evince.desktop"),
|
||||
Some((String::from("evince"), PageArgument::ZeroBasedIndex)),
|
||||
"A desktop entry is not the name of a binary, and the binary is what we have to start.",
|
||||
);
|
||||
|
||||
assert_eq!(linux_page_aware_program("okularApplication_pdf.desktop"), Some((String::from("okular"), PageArgument::OkularPage)));
|
||||
assert_eq!(linux_page_aware_program("org.pwmt.zathura.desktop"), Some((String::from("zathura"), PageArgument::ZathuraPage)));
|
||||
assert_eq!(linux_page_aware_program("firefox.desktop"), Some((String::from("firefox"), PageArgument::UrlFragment)));
|
||||
assert_eq!(linux_page_aware_program("google-chrome.desktop"), Some((String::from("google-chrome"), PageArgument::UrlFragment)));
|
||||
assert_eq!(linux_page_aware_program("chromium_chromium.desktop"), Some((String::from("chromium"), PageArgument::UrlFragment)));
|
||||
assert_eq!(linux_page_aware_program("com.example.SomeViewer.desktop"), None, "A viewer we know nothing about opens its first page.");
|
||||
}
|
||||
}
|
||||
@ -827,6 +827,16 @@ fn route_from_content(fmt: FileFormat) -> Option<ExtractionRoute> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the content of a file is a program rather than something to read.
|
||||
///
|
||||
/// The extension is not asked: recognizing a program by its content is the whole point, because a
|
||||
/// program which carries a harmless extension is exactly the case worth stopping. Answering this
|
||||
/// here keeps one place in charge of what counts as a program — the reader which refuses to read
|
||||
/// one, and the endpoint which refuses to hand one to the system.
|
||||
pub(crate) fn is_executable_content(fmt: FileFormat) -> bool {
|
||||
matches!(route_from_content(fmt), Some(ExtractionRoute::Executable))
|
||||
}
|
||||
|
||||
async fn stream_data(file_path: &str, extract_images: bool, stream_id: &str) -> Result<ChunkStream> {
|
||||
if !Path::new(file_path).exists() {
|
||||
error!("File does not exist: '{file_path}'");
|
||||
|
||||
@ -55,6 +55,7 @@ pub fn start_runtime_api() {
|
||||
.route("/select/files", post(crate::file_actions::select_files))
|
||||
.route("/save/file", post(crate::file_actions::save_file))
|
||||
.route("/open/path", post(crate::file_actions::open_path_in_file_manager))
|
||||
.route("/open/document", post(crate::file_actions::open_document))
|
||||
.route("/secrets/get", post(crate::secret::get_secret))
|
||||
.route("/secrets/store", post(crate::secret::store_secret))
|
||||
.route("/secrets/delete", post(crate::secret::delete_secret))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user