//! 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 { #[allow(unused_mut)] let mut candidates: Vec = 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!( "\n\n\ DarkRoom manual\n\ \n\

Open the DarkRoom manual

\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\">