|
|
|
@@ -0,0 +1,221 @@
|
|
|
|
|
//! TRACES: FR-UI-4
|
|
|
|
|
//! Opening the bundled manual, at a section when asked for one.
|
|
|
|
|
//!
|
|
|
|
|
//! The manual is `docs/manual/index.html` and its pictures, rendered by
|
|
|
|
|
//! `tools/traceability` and installed by each package beside the models:
|
|
|
|
|
//! `/usr/share/darkroom/manual` on Linux, `manual\` beside the executable on
|
|
|
|
|
//! Windows, `assets/manual` inside the APK. Bundled rather than linked to on
|
|
|
|
|
//! the forge, because the moment somebody opens a help sheet is not a moment
|
|
|
|
|
//! to require a network.
|
|
|
|
|
//!
|
|
|
|
|
//! # Desktop: the system browser, by way of a one-line page
|
|
|
|
|
//!
|
|
|
|
|
//! A section is a fragment — `index.html#rating-and-flagging` — and the
|
|
|
|
|
//! fragment is exactly what the platforms' openers lose. `xdg-open` in its
|
|
|
|
|
//! generic mode, and `url.dll,FileProtocolHandler` on Windows, turn a `file:`
|
|
|
|
|
//! URL into a path before they hand it on, and a path has no fragment: the
|
|
|
|
|
//! manual opens at the top and the "See it" link has done nothing a plain
|
|
|
|
|
//! "Manual" link would not. So a section is opened through a small page of
|
|
|
|
|
//! our own whose only content is a redirect to the full URL, fragment and
|
|
|
|
|
//! all; the opener is handed that page's *path*, which every opener keeps
|
|
|
|
|
//! intact, and the browser follows the redirect itself. Written to the user
|
|
|
|
|
//! data directory, overwritten on each use.
|
|
|
|
|
//!
|
|
|
|
|
//! # Android: a WebView of our own
|
|
|
|
|
//!
|
|
|
|
|
//! Android has no path to hand a browser. An asset inside the APK is not a
|
|
|
|
|
//! file; a copy unpacked to app-private storage is a file no other app may
|
|
|
|
|
//! read, and a `file:` URI handed across apps is refused outright since API
|
|
|
|
|
//! 24. A content provider would serve the page, but a browser then asks the
|
|
|
|
|
//! same provider for every picture by a relative URL it resolves against a
|
|
|
|
|
//! `content:` authority — which the browsers do not reliably do. So the page is
|
|
|
|
|
//! shown by `ManualActivity`, a WebView that reads
|
|
|
|
|
//! `file:///android_asset/manual/index.html` straight out of the APK, pictures
|
|
|
|
|
//! and fragment included, with nothing unpacked.
|
|
|
|
|
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
|
|
|
|
|
/// The Android activity that shows the manual. Named by string in the Intent,
|
|
|
|
|
/// so no class has to be loaded to start it; the manifest test in
|
|
|
|
|
/// `darkroom-android` checks the manifest declares this exact name.
|
|
|
|
|
pub const ANDROID_ACTIVITY: &str = "paris.tourolle.darkroom.ManualActivity";
|
|
|
|
|
|
|
|
|
|
/// The Intent extra carrying the section, read by `ManualActivity`.
|
|
|
|
|
pub const ANDROID_EXTRA_ANCHOR: &str = "anchor";
|
|
|
|
|
|
|
|
|
|
/// Where the installed manual is, if a package installed one.
|
|
|
|
|
///
|
|
|
|
|
/// The package directories first, as the models are found. A development
|
|
|
|
|
/// build also looks in the checkout it was compiled from, so `cargo run`
|
|
|
|
|
/// opens the page the tree has; a release build never does, because the
|
|
|
|
|
/// checkout is not on the user's machine.
|
|
|
|
|
pub fn page() -> Option<PathBuf> {
|
|
|
|
|
#[allow(unused_mut)]
|
|
|
|
|
let mut candidates: Vec<PathBuf> = dr_plat::system_data_dirs()
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|d| d.join("manual").join("index.html"))
|
|
|
|
|
.collect();
|
|
|
|
|
#[cfg(debug_assertions)]
|
|
|
|
|
candidates.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../docs/manual/index.html"));
|
|
|
|
|
candidates.into_iter().find(|p| p.is_file())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A section's anchor, as it may appear in a URL.
|
|
|
|
|
///
|
|
|
|
|
/// Anchors are the forge's slugs — lower-case letters, digits and hyphens — so
|
|
|
|
|
/// anything else in one is not a section of this manual and is dropped rather
|
|
|
|
|
/// than escaped: it came from the generated gesture table, and a table entry
|
|
|
|
|
/// that needs escaping is a bug to notice, not to route around.
|
|
|
|
|
fn clean_anchor(anchor: &str) -> String {
|
|
|
|
|
anchor
|
|
|
|
|
.chars()
|
|
|
|
|
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The redirect page for one section of the manual at `target`.
|
|
|
|
|
#[cfg(not(target_os = "android"))]
|
|
|
|
|
fn redirect_page(target: &str) -> String {
|
|
|
|
|
format!(
|
|
|
|
|
"<!DOCTYPE html>\n<html lang=\"en\"><head><meta charset=\"utf-8\">\n\
|
|
|
|
|
<title>DarkRoom manual</title>\n\
|
|
|
|
|
<meta http-equiv=\"refresh\" content=\"0; url={target}\">\n\
|
|
|
|
|
</head><body><p><a href=\"{target}\">Open the DarkRoom manual</a></p></body></html>\n"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Open the manual, at `anchor` when it is not empty.
|
|
|
|
|
///
|
|
|
|
|
/// The error is a sentence for the status line: the one case a user can do
|
|
|
|
|
/// anything about is a manual that is not installed.
|
|
|
|
|
pub fn open(anchor: &str) -> Result<(), String> {
|
|
|
|
|
let anchor = clean_anchor(anchor);
|
|
|
|
|
#[cfg(target_os = "android")]
|
|
|
|
|
{
|
|
|
|
|
android_open(&anchor)
|
|
|
|
|
}
|
|
|
|
|
#[cfg(not(target_os = "android"))]
|
|
|
|
|
{
|
|
|
|
|
desktop_open(&anchor)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(target_os = "android"))]
|
|
|
|
|
fn desktop_open(anchor: &str) -> Result<(), String> {
|
|
|
|
|
let Some(page) = page() else {
|
|
|
|
|
return Err("The manual is not installed with this copy of DarkRoom".into());
|
|
|
|
|
};
|
|
|
|
|
// Unix only: on Windows `canonicalize` answers with a `\\?\` verbatim
|
|
|
|
|
// path, which is not one a `file:` URL can be made from — and there the
|
|
|
|
|
// path is the executable's directory, already absolute.
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
let page = page.canonicalize().unwrap_or(page);
|
|
|
|
|
if anchor.is_empty() {
|
|
|
|
|
log::info!("opening the manual at {}", page.display());
|
|
|
|
|
return crate::launch_ui::hand_to_system(&page.to_string_lossy())
|
|
|
|
|
.map_err(|e| format!("Could not open the manual: {e}"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let Ok(mut url) = url::Url::from_file_path(&page) else {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"{} is not a path a browser can open",
|
|
|
|
|
page.display()
|
|
|
|
|
));
|
|
|
|
|
};
|
|
|
|
|
url.set_fragment(Some(anchor));
|
|
|
|
|
|
|
|
|
|
let link = dr_plat::base_dir(dr_plat::Base::Data).join("manual-link.html");
|
|
|
|
|
if let Some(dir) = link.parent() {
|
|
|
|
|
std::fs::create_dir_all(dir).map_err(|e| format!("Could not open the manual: {e}"))?;
|
|
|
|
|
}
|
|
|
|
|
std::fs::write(&link, redirect_page(url.as_str()))
|
|
|
|
|
.map_err(|e| format!("Could not open the manual: {e}"))?;
|
|
|
|
|
log::info!("opening the manual at {url} by way of {}", link.display());
|
|
|
|
|
crate::launch_ui::hand_to_system(&link.to_string_lossy())
|
|
|
|
|
.map_err(|e| format!("Could not open the manual: {e}"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Start `ManualActivity` with the section as an extra.
|
|
|
|
|
///
|
|
|
|
|
/// `Intent.setClassName(Context, String)` rather than a class object: the
|
|
|
|
|
/// activity's class is in this APK's dex, which a native thread's `FindClass`
|
|
|
|
|
/// cannot see (see `darkroom-android`'s `load_class`), and a name needs no
|
|
|
|
|
/// class at all. The extra is a string rather than the Intent's data URI,
|
|
|
|
|
/// because a `file:` data URI trips the platform's exposure check.
|
|
|
|
|
#[cfg(target_os = "android")]
|
|
|
|
|
fn android_open(anchor: &str) -> Result<(), String> {
|
|
|
|
|
let ctx = ndk_context::android_context();
|
|
|
|
|
if ctx.vm().is_null() || ctx.context().is_null() {
|
|
|
|
|
return Err("no Android context available".into());
|
|
|
|
|
}
|
|
|
|
|
// SAFETY: the pointer comes from ndk_context, which android-activity fills
|
|
|
|
|
// in with the process's real JavaVM before any Rust runs.
|
|
|
|
|
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
|
|
|
|
|
let raw_activity: jni::sys::jobject = ctx.context().cast();
|
|
|
|
|
|
|
|
|
|
vm.attach_current_thread(|env| {
|
|
|
|
|
// SAFETY: valid for this frame, which is all the Intent needs.
|
|
|
|
|
let activity = unsafe { jni::objects::JObject::from_raw(env, raw_activity) };
|
|
|
|
|
let intent = env.new_object(
|
|
|
|
|
jni::jni_str!("android/content/Intent"),
|
|
|
|
|
jni::jni_sig!("()V"),
|
|
|
|
|
&[],
|
|
|
|
|
)?;
|
|
|
|
|
let class = env.new_string(ANDROID_ACTIVITY)?;
|
|
|
|
|
env.call_method(
|
|
|
|
|
&intent,
|
|
|
|
|
jni::jni_str!("setClassName"),
|
|
|
|
|
jni::jni_sig!("(Landroid/content/Context;Ljava/lang/String;)Landroid/content/Intent;"),
|
|
|
|
|
&[(&activity).into(), (&class).into()],
|
|
|
|
|
)?;
|
|
|
|
|
let key = env.new_string(ANDROID_EXTRA_ANCHOR)?;
|
|
|
|
|
let value = env.new_string(anchor)?;
|
|
|
|
|
env.call_method(
|
|
|
|
|
&intent,
|
|
|
|
|
jni::jni_str!("putExtra"),
|
|
|
|
|
jni::jni_sig!("(Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent;"),
|
|
|
|
|
&[(&key).into(), (&value).into()],
|
|
|
|
|
)?;
|
|
|
|
|
env.call_method(
|
|
|
|
|
&activity,
|
|
|
|
|
jni::jni_str!("startActivity"),
|
|
|
|
|
jni::jni_sig!("(Landroid/content/Intent;)V"),
|
|
|
|
|
&[(&intent).into()],
|
|
|
|
|
)?;
|
|
|
|
|
if env.exception_check() {
|
|
|
|
|
env.exception_clear();
|
|
|
|
|
return Err(jni::errors::Error::JavaException);
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
})
|
|
|
|
|
.map_err(|e: jni::errors::Error| format!("Could not open the manual: {e}"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn an_anchor_keeps_only_what_a_slug_can_hold() {
|
|
|
|
|
assert_eq!(clean_anchor("rating-and-flagging"), "rating-and-flagging");
|
|
|
|
|
assert_eq!(clean_anchor("x\"><script>"), "xscript");
|
|
|
|
|
assert_eq!(clean_anchor(""), "");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(target_os = "android"))]
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_redirect_carries_the_fragment() {
|
|
|
|
|
let page = redirect_page("file:///usr/share/darkroom/manual/index.html#looking-closer");
|
|
|
|
|
assert!(page.contains(
|
|
|
|
|
"content=\"0; url=file:///usr/share/darkroom/manual/index.html#looking-closer\""
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A development build finds the page in the checkout, so this is also
|
|
|
|
|
/// the test that the committed page is where the packagers copy it from.
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_development_build_finds_the_page() {
|
|
|
|
|
let page = page().expect("docs/manual/index.html is not where the packagers expect it");
|
|
|
|
|
assert!(page.ends_with("index.html"));
|
|
|
|
|
}
|
|
|
|
|
}
|