Shared groundwork: - paths.rs: folders for data, settings and downloads per system (Linux XDG unchanged, Windows %APPDATA%/%LOCALAPPDATA%, Android app folder) - Linux-only parts behind cfg: Wayland on-screen keyboard (osk/wayland.rs), display backend and app id, the vendored winit touch extensions - run_with(event_loop) so that other entry points can start the app Windows (#48): - cross-compiled with MinGW into a portable ink-notes_<version>_x86_64.exe (system DLLs only), no console window in release builds - file picker: user folders and drives; held notebook locks are detected (ERROR_LOCK_VIOLATION) - updater: replaces the running .exe via rename, active at the next start - octotablet panicking without the Tablet PC components no longer crashes the app - release: .exe built in CI and part of the signed checksums - all library tests pass under wine Android (#42): - android_main entry point, winit with android-native-activity, android_logger; octotablet and arboard left out there - cargo check and the cdylib build for aarch64-linux-android pass; the window surface does not follow the activity lifecycle yet Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
beb780b420
commit
e5a8601f87
26 files changed
+762
-270
No files matched your search
+57
-43
@@ -3,6 +3,18 @@
|
||||
//! notebook persistence (autosave). Lives in the library so that other entry points (e.g.
|
||||
//! Android, Issue #42) can start it too.
|
||||
|
||||
// On Android the pen state is unused until pen input arrives there (Issue #44)
|
||||
#![cfg_attr(
|
||||
target_os = "android",
|
||||
allow(
|
||||
unused_mut,
|
||||
unused_variables,
|
||||
unused_assignments,
|
||||
unused_imports,
|
||||
dead_code
|
||||
)
|
||||
)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc::{self, Sender};
|
||||
use std::sync::Arc;
|
||||
@@ -11,10 +23,8 @@ use winit::{
|
||||
event::{
|
||||
ElementState, Event, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent,
|
||||
},
|
||||
event_loop::{ControlFlow, EventLoopBuilder},
|
||||
event_loop::ControlFlow,
|
||||
keyboard::{Key, ModifiersState, NamedKey},
|
||||
platform::{wayland::EventLoopBuilderExtWayland, x11::EventLoopBuilderExtX11},
|
||||
raw_window_handle::{HasDisplayHandle, RawDisplayHandle},
|
||||
window::{Window, WindowBuilder},
|
||||
};
|
||||
|
||||
@@ -31,7 +41,7 @@ use crate::image_layer::TextureSource;
|
||||
use crate::library::Library;
|
||||
use crate::osk::{self, KeyboardRequester};
|
||||
use crate::pdf_doc::PdfRenderService;
|
||||
use crate::platform::{self, DisplayBackend};
|
||||
use crate::platform;
|
||||
use crate::selection::{
|
||||
render_selection_overlay, scale_factors, DragMode, SelectionHandleHit, SelectionState,
|
||||
};
|
||||
@@ -163,6 +173,7 @@ fn activate_tool(tool: ActiveTool, ui_state: &mut UiState, canvas: &mut CanvasSt
|
||||
|
||||
/// Kennung einer Stifttaste, die über Programmstarts hinweg gleich bleibt (die Tablet-
|
||||
/// Schnittstelle liefert nur eine undurchsichtige, aber vergleichbare Kennung).
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn stylus_button_key(id: &octotablet::tool::ButtonID) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
// DefaultHasher::new() verwendet feste Schlüssel: gleiche Taste, gleicher Wert
|
||||
@@ -523,7 +534,7 @@ impl Drop for SlowEventLog {
|
||||
/// Zwischenablage: interne Objekte (Strg+C in ink-notes), Bilder aus der
|
||||
/// Systemzwischenablage (Screenshots, aus anderen Programmen kopierte Bilder) und Text.
|
||||
struct ClipboardBridge {
|
||||
system: Option<arboard::Clipboard>,
|
||||
system: Option<platform::clipboard::Clipboard>,
|
||||
/// Prüfsumme des Systembildes zum Zeitpunkt des letzten internen Kopierens: Strg+V fügt
|
||||
/// das Systembild nur ein, wenn es danach kopiert wurde (die jüngste Kopie gewinnt)
|
||||
image_hash_at_copy: Option<[u8; 32]>,
|
||||
@@ -544,9 +555,9 @@ impl ClipboardBridge {
|
||||
}
|
||||
|
||||
/// Bild in der Systemzwischenablage samt Prüfsumme.
|
||||
fn system_image(&mut self) -> Option<(arboard::ImageData<'static>, [u8; 32])> {
|
||||
fn system_image(&mut self) -> Option<(platform::clipboard::ImageData<'static>, [u8; 32])> {
|
||||
if self.system.is_none() {
|
||||
self.system = arboard::Clipboard::new()
|
||||
self.system = platform::clipboard::Clipboard::new()
|
||||
.map_err(|e| eprintln!("[Zwischenablage] Nicht verfügbar: {e}"))
|
||||
.ok();
|
||||
}
|
||||
@@ -563,7 +574,7 @@ impl ClipboardBridge {
|
||||
/// Text in der Systemzwischenablage (nicht nur Leerraum).
|
||||
fn system_text(&mut self) -> Option<String> {
|
||||
if self.system.is_none() {
|
||||
self.system = arboard::Clipboard::new().ok();
|
||||
self.system = platform::clipboard::Clipboard::new().ok();
|
||||
}
|
||||
let text = self.system.as_mut()?.get_text().ok()?;
|
||||
(!text.trim().is_empty()).then_some(text)
|
||||
@@ -596,12 +607,12 @@ impl ClipboardBridge {
|
||||
}
|
||||
};
|
||||
if self.system.is_none() {
|
||||
self.system = arboard::Clipboard::new().ok();
|
||||
self.system = platform::clipboard::Clipboard::new().ok();
|
||||
}
|
||||
let Some(system) = self.system.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
let data = arboard::ImageData {
|
||||
let data = platform::clipboard::ImageData {
|
||||
width: image.width as usize,
|
||||
height: image.height as usize,
|
||||
bytes: std::borrow::Cow::Owned(image.pixels),
|
||||
@@ -1252,7 +1263,16 @@ fn insert_image_file(
|
||||
}
|
||||
|
||||
/// Runs the app until the window is closed (the desktop entry point is `src/main.rs`).
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Anzeigesystem wählen: Unter WSL (WSLg) bricht Wayland ab, X11 (Xwayland) läuft stabil
|
||||
run_with(platform::desktop_event_loop()?)
|
||||
}
|
||||
|
||||
/// Runs the app on `event_loop` (desktop: [`run`], Android: `android_main` in `lib.rs`).
|
||||
pub fn run_with(
|
||||
event_loop: winit::event_loop::EventLoop<()>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Gespeicherte UI-Einstellungen; zuerst, damit alle Texte in der gewählten Sprache sind
|
||||
let ui_settings_path = UiSettings::default_path();
|
||||
let mut ui_settings = ui_settings_path
|
||||
@@ -1261,20 +1281,6 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.unwrap_or_default();
|
||||
i18n::set_language(ui_settings.language.unwrap_or_else(Language::system));
|
||||
|
||||
// Anzeigesystem wählen: Unter WSL (WSLg) bricht Wayland ab, X11 (Xwayland) läuft stabil
|
||||
let mut event_loop_builder = EventLoopBuilder::new();
|
||||
match platform::display_backend_from_env() {
|
||||
DisplayBackend::X11 => {
|
||||
eprintln!("[Anzeige] Verwende X11 (unter WSL Standard; übersteuerbar mit INK_NOTES_DISPLAY=wayland)");
|
||||
event_loop_builder.with_x11();
|
||||
}
|
||||
DisplayBackend::Wayland => {
|
||||
eprintln!("[Anzeige] Verwende Wayland (INK_NOTES_DISPLAY=wayland)");
|
||||
event_loop_builder.with_wayland();
|
||||
}
|
||||
DisplayBackend::Automatic => {}
|
||||
}
|
||||
let event_loop = event_loop_builder.build()?;
|
||||
// App-Kennung wie die .desktop-Datei (eu.pansi.ink-notes): Daran ordnen Dock und Taskleiste
|
||||
// das Fenster dem App-Symbol zu (Wayland: app_id, X11: WM_CLASS). Das Fenstersymbol selbst
|
||||
// nutzt nur X11.
|
||||
@@ -1282,10 +1288,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.with_title("ink-notes")
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(1280.0, 850.0))
|
||||
.with_window_icon(app_icon());
|
||||
let builder =
|
||||
winit::platform::wayland::WindowBuilderExtWayland::with_name(builder, APP_ID, "ink-notes");
|
||||
let builder =
|
||||
winit::platform::x11::WindowBuilderExtX11::with_name(builder, APP_ID, "ink-notes");
|
||||
let builder = platform::with_app_id(builder, APP_ID);
|
||||
let window = Arc::new(builder.build(&event_loop)?);
|
||||
|
||||
let mut canvas = match pollster::block_on(CanvasState::new(window.clone())) {
|
||||
@@ -1296,25 +1299,32 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
};
|
||||
|
||||
let mut tablet_manager = match octotablet::builder::Builder::default().build_shared(&window) {
|
||||
Ok(mgr) => {
|
||||
// Android has no octotablet: the pen arrives as touch events there (Issue #44).
|
||||
// octotablet panics on Windows without the Tablet PC components (RealTimeStylus not
|
||||
// registered); the app then runs with mouse and touch events only
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let tablet_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
octotablet::builder::Builder::default().build_shared(&window)
|
||||
}));
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let mut tablet_manager = match tablet_result {
|
||||
Ok(Ok(mgr)) => {
|
||||
println!("[Input] Octotablet Stylus-/Tablet-Manager erfolgreich initialisiert (Wayland/X11 Tablet-Protokoll aktiv).");
|
||||
Some(mgr)
|
||||
}
|
||||
Err(err) => {
|
||||
Ok(Err(err)) => {
|
||||
println!("[Input] Hinweis: Octotablet Tablet-Manager nicht aktiv ({err}). Nutze Standard-Event-Pipeline.");
|
||||
None
|
||||
}
|
||||
Err(_) => {
|
||||
println!("[Input] Hinweis: Octotablet Tablet-Manager abgestürzt. Nutze Standard-Event-Pipeline.");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Bildschirmtastatur (nur Wayland): eigenes text-input-Objekt, siehe osk.rs
|
||||
let mut keyboard_requester = match window.display_handle().map(|handle| handle.as_raw()) {
|
||||
// Safety: `window` lebt bis zum Ende von main, die Event-Schleife besitzt beide
|
||||
Ok(RawDisplayHandle::Wayland(handle)) => {
|
||||
Some(unsafe { KeyboardRequester::new(handle.display.as_ptr()) })
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
// Safety: `window` lives until the end of run(), the event loop owns both
|
||||
let mut keyboard_requester = unsafe { KeyboardRequester::for_window(&window) };
|
||||
let mut ime_was_active = false;
|
||||
let mut ime_composing = false;
|
||||
|
||||
@@ -1515,6 +1525,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// that touch it while the pen is near, are ignored until they are lifted
|
||||
let mut palm_touch_ids: std::collections::HashSet<u64> = std::collections::HashSet::new();
|
||||
let mut stylus_left_at: Option<Instant> = None;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let mut stylus_tool_id: Option<octotablet::tool::ID> = None;
|
||||
// Gedrückte Stifttaste (Kennung, Belegung) und das Werkzeug, das während eines Strichs mit
|
||||
// einer Werkzeug-Taste (Auswählen, Textmarker, Stift) vertreten wird
|
||||
@@ -1695,7 +1706,10 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
// Stylus-/Tablet-Events via Octotablet abfragen (Wayland/X11 Tablet-Protokoll für aktive Stifte wie Lazarite)
|
||||
#[cfg(not(target_os = "android"))]
|
||||
if let Some(ref mut mgr) = tablet_manager {
|
||||
// Infallible on Windows
|
||||
#[allow(irrefutable_let_patterns)]
|
||||
if let Ok(tablet_events) = mgr.pump() {
|
||||
let scale = window.scale_factor() as f32;
|
||||
let now = start_time.elapsed().as_secs_f64();
|
||||
@@ -1744,8 +1758,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
pinch_prev_distance = None;
|
||||
pinch_prev_center = None;
|
||||
stylus_left_at = None;
|
||||
#[cfg(target_os = "linux")]
|
||||
winit::platform::wayland::ignore_decoration_touches_until(Some(
|
||||
platform::ignore_decoration_touches_until(Some(
|
||||
Instant::now() + Duration::from_secs(24 * 3600),
|
||||
));
|
||||
window.request_redraw();
|
||||
@@ -1760,8 +1773,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
| octotablet::events::ToolEvent::Removed => {
|
||||
if is_stylus_in_proximity {
|
||||
stylus_left_at = Some(Instant::now());
|
||||
#[cfg(target_os = "linux")]
|
||||
winit::platform::wayland::ignore_decoration_touches_until(Some(
|
||||
platform::ignore_decoration_touches_until(Some(
|
||||
Instant::now() + PALM_GRACE,
|
||||
));
|
||||
}
|
||||
@@ -3038,7 +3050,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let handled_at = Instant::now();
|
||||
let event_time = match touch.phase {
|
||||
TouchPhase::Cancelled => None,
|
||||
phase => winit::platform::wayland::take_touch_time(touch.id, phase),
|
||||
phase => platform::take_touch_time(touch.id, phase),
|
||||
};
|
||||
if let (TouchPhase::Started, Some(time)) = (touch.phase, event_time) {
|
||||
touch_clock = Some((handled_at, time));
|
||||
@@ -4139,6 +4151,8 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
UiAction::InstallOrOpenUpdate(path) => {
|
||||
if let Err(err) = updater.install_or_open(&path) {
|
||||
ui_state.set_export_message(t!("Update-Fehler: {err}", err = err));
|
||||
} else if matches!(updater.runtime(), crate::updater::RuntimeEnvironment::WindowsExe { .. }) {
|
||||
ui_state.set_export_message(t!("Update installiert – gilt ab dem nächsten Start"));
|
||||
} else {
|
||||
ui_state.set_export_message(t!("Update gestartet / geöffnet"));
|
||||
}
|
||||
|
||||
+3
-15
@@ -690,21 +690,9 @@ impl AutosaveController {
|
||||
|
||||
/// Liefert das Verzeichnis für Notfall-Wiederherstellungsdateien ($XDG_DATA_HOME/ink-notes/recovery).
|
||||
pub fn recovery_dir() -> PathBuf {
|
||||
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
||||
if !xdg.is_empty() {
|
||||
return PathBuf::from(xdg).join("ink-notes").join("recovery");
|
||||
}
|
||||
}
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
if !home.is_empty() {
|
||||
return PathBuf::from(home)
|
||||
.join(".local")
|
||||
.join("share")
|
||||
.join("ink-notes")
|
||||
.join("recovery");
|
||||
}
|
||||
}
|
||||
PathBuf::from(".").join(".recovery")
|
||||
crate::paths::data_dir()
|
||||
.map(|d| d.join("recovery"))
|
||||
.unwrap_or_else(|| PathBuf::from(".").join(".recovery"))
|
||||
}
|
||||
|
||||
impl Drop for AutosaveController {
|
||||
|
||||
+9
-12
@@ -56,19 +56,16 @@ pub fn default_documents_dir() -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
if !home.trim().is_empty() {
|
||||
let home = PathBuf::from(home);
|
||||
let user_dirs =
|
||||
std::fs::read_to_string(home.join(".config/user-dirs.dirs")).unwrap_or_default();
|
||||
if let Some(dir) =
|
||||
crate::file_picker::parse_user_dir(&user_dirs, "XDG_DOCUMENTS_DIR", &home)
|
||||
.filter(|dir| *dir != home)
|
||||
{
|
||||
return dir;
|
||||
}
|
||||
return home.join("Documents");
|
||||
if let Some(home) = crate::paths::home_dir() {
|
||||
let user_dirs =
|
||||
std::fs::read_to_string(home.join(".config/user-dirs.dirs")).unwrap_or_default();
|
||||
if let Some(dir) =
|
||||
crate::file_picker::parse_user_dir(&user_dirs, "XDG_DOCUMENTS_DIR", &home)
|
||||
.filter(|dir| *dir != home)
|
||||
{
|
||||
return dir;
|
||||
}
|
||||
return home.join("Documents");
|
||||
}
|
||||
|
||||
PathBuf::from(".")
|
||||
|
||||
+26
-1
@@ -140,7 +140,7 @@ pub fn list_dir(dir: &Path, kind: PickerKind) -> std::io::Result<Vec<PickerEntry
|
||||
/// mit den Ordnern aus `~/.config/user-dirs.dirs` (z. B. „Bilder“ statt „Pictures“). Die
|
||||
/// Bezeichnungen sind deutsch, angezeigt werden sie mit `i18n::tr`.
|
||||
pub fn shortcuts() -> Vec<(&'static str, PathBuf)> {
|
||||
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
|
||||
let Some(home) = crate::paths::home_dir() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let user_dirs =
|
||||
@@ -162,6 +162,30 @@ pub fn shortcuts() -> Vec<(&'static str, PathBuf)> {
|
||||
list.push((tn!("🖥 Schreibtisch"), p));
|
||||
}
|
||||
list.push((tn!("🏠 Persönlicher Ordner"), home));
|
||||
// Drives (Windows has no single root)
|
||||
#[cfg(windows)]
|
||||
{
|
||||
const DRIVES: [&str; 24] = [
|
||||
"💽 C:", "💽 D:", "💽 E:", "💽 F:", "💽 G:", "💽 H:", "💽 I:", "💽 J:", "💽 K:",
|
||||
"💽 L:", "💽 M:", "💽 N:", "💽 O:", "💽 P:", "💽 Q:", "💽 R:", "💽 S:", "💽 T:",
|
||||
"💽 U:", "💽 V:", "💽 W:", "💽 X:", "💽 Y:", "💽 Z:",
|
||||
];
|
||||
// Looked up once: a disconnected network drive can take seconds to answer
|
||||
static FOUND: std::sync::OnceLock<Vec<(&str, PathBuf)>> = std::sync::OnceLock::new();
|
||||
let found = FOUND.get_or_init(|| {
|
||||
DRIVES
|
||||
.iter()
|
||||
.map(|label| {
|
||||
(
|
||||
*label,
|
||||
PathBuf::from(format!("{}\\", &label[label.len() - 2..])),
|
||||
)
|
||||
})
|
||||
.filter(|(_, root)| root.is_dir())
|
||||
.collect()
|
||||
});
|
||||
list.extend(found.iter().cloned());
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
@@ -254,6 +278,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn test_parse_user_dirs() {
|
||||
let home = Path::new("/home/anna");
|
||||
let content = "# Kommentar\nXDG_DESKTOP_DIR=\"$HOME/Schreibtisch\"\nXDG_PICTURES_DIR=\"$HOME/Bilder\"\nXDG_MUSIC_DIR=\"/daten/Musik\"\n";
|
||||
|
||||
@@ -484,4 +484,9 @@ pub const EN: &[(&str, &str)] = &[
|
||||
("✔ Diesen Ordner wählen", "✔ Choose this folder"),
|
||||
("📁 Ändern …", "📁 Change …"),
|
||||
("📁 Speicherort wählen", "📁 Choose storage location"),
|
||||
("Update installiert – gilt ab dem nächsten Start", "Update installed – takes effect at the next start"),
|
||||
("🔄 Update installieren", "🔄 Install update"),
|
||||
("Konnte Backup ({path}) nicht anlegen: {e}. Update abgebrochen, altes Programm unverändert.", "Could not create backup ({path}): {e}. Update cancelled, old program unchanged."),
|
||||
("Konnte Programm nicht ersetzen: {e}. Altes Programm bleibt unverändert.", "Could not replace the program: {e}. The old program stays unchanged."),
|
||||
("Ungültiger Programmpfad.", "Invalid program path."),
|
||||
];
|
||||
+34
@@ -18,6 +18,7 @@ pub mod import;
|
||||
pub mod library;
|
||||
pub mod osk;
|
||||
pub mod page_export;
|
||||
pub mod paths;
|
||||
pub mod pdf_doc;
|
||||
pub mod pdf_merge;
|
||||
pub mod platform;
|
||||
@@ -33,3 +34,36 @@ pub mod ui;
|
||||
pub mod updater;
|
||||
pub mod viewport;
|
||||
pub mod xopp;
|
||||
|
||||
/// Entry point on Android (Issue #42), called by the native activity.
|
||||
///
|
||||
/// Builds with `cargo rustc --lib --crate-type cdylib --target aarch64-linux-android`. Not
|
||||
/// usable yet: the window surface still has to be created on `Resumed` and dropped on
|
||||
/// `Suspended`.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
fn android_main(app: winit::platform::android::activity::AndroidApp) {
|
||||
use winit::platform::android::EventLoopBuilderExtAndroid;
|
||||
|
||||
android_logger::init_once(
|
||||
android_logger::Config::default()
|
||||
.with_max_level(log::LevelFilter::Info)
|
||||
.with_tag("ink-notes"),
|
||||
);
|
||||
if let Some(dir) = app.internal_data_path() {
|
||||
paths::set_app_dir(dir);
|
||||
}
|
||||
let event_loop = match winit::event_loop::EventLoopBuilder::new()
|
||||
.with_android_app(app)
|
||||
.build()
|
||||
{
|
||||
Ok(event_loop) => event_loop,
|
||||
Err(err) => {
|
||||
log::error!("event loop: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(err) = app::run_with(event_loop) {
|
||||
log::error!("{err}");
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,14 @@
|
||||
//!
|
||||
//! Desktop entry point; the app itself is [`ink_notes::app`].
|
||||
|
||||
// No console window next to the app on Windows (debug builds keep it for the log)
|
||||
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
ink_notes::app::run()
|
||||
}
|
||||
|
||||
/// On Android the app starts in `android_main` of the library.
|
||||
#[cfg(target_os = "android")]
|
||||
fn main() {}
|
||||
+21
-142
@@ -12,21 +12,29 @@
|
||||
//! ohnehin nicht aus. Daher meldet das eigene Objekt einen Platzhalter als Umgebungstext und
|
||||
//! übersetzt die Löschwünsche in Tastendrücke (Rücktaste/Entf) für egui.
|
||||
|
||||
use std::ffi::c_void;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod wayland;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use wayland::KeyboardRequester;
|
||||
|
||||
use wayland_client::{
|
||||
delegate_noop,
|
||||
protocol::{wl_registry, wl_seat},
|
||||
Connection, Dispatch, EventQueue, QueueHandle,
|
||||
};
|
||||
use wayland_protocols::wp::text_input::zv3::client::{
|
||||
zwp_text_input_manager_v3::ZwpTextInputManagerV3,
|
||||
zwp_text_input_v3::{self, ZwpTextInputV3},
|
||||
};
|
||||
/// Elsewhere the system shows the on-screen keyboard by itself; nothing to request.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub struct KeyboardRequester;
|
||||
|
||||
/// Platzhalter-Umgebungstext: ein Zeichen vor der Schreibmarke, damit die Rücktaste der
|
||||
/// Bildschirmtastatur immer etwas zu löschen hat (ein Byte = ein Zeichen)
|
||||
const SURROUNDING_PLACEHOLDER: &str = " ";
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
impl KeyboardRequester {
|
||||
/// # Safety
|
||||
/// Nothing to uphold; `unsafe` like the Wayland version.
|
||||
pub unsafe fn for_window(_window: &winit::window::Window) -> Option<Self> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn request(&mut self) {}
|
||||
|
||||
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Wandelt Texteingaben ohne Vorschau (Preedit) in normale Texteingaben um.
|
||||
///
|
||||
@@ -74,135 +82,6 @@ pub fn should_request(was_active: bool, active: bool, pressed: bool) -> bool {
|
||||
active && (!was_active || pressed)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
seat: Option<wl_seat::WlSeat>,
|
||||
manager: Option<ZwpTextInputManagerV3>,
|
||||
text_input: Option<ZwpTextInputV3>,
|
||||
/// Von der Bildschirmtastatur gewünschtes Löschen (Zeichen vor/nach der Schreibmarke)
|
||||
delete_back: u32,
|
||||
delete_forward: u32,
|
||||
}
|
||||
|
||||
/// Eigenes text-input-Objekt, mit dem ink-notes die Bildschirmtastatur anfordert
|
||||
pub struct KeyboardRequester {
|
||||
conn: Connection,
|
||||
queue: EventQueue<State>,
|
||||
state: State,
|
||||
}
|
||||
|
||||
impl KeyboardRequester {
|
||||
/// Hängt sich an die Wayland-Verbindung des Fensters.
|
||||
///
|
||||
/// # Safety
|
||||
/// `wl_display` muss auf die `wl_display` des Fensters zeigen und gültig bleiben, solange
|
||||
/// der `KeyboardRequester` lebt.
|
||||
pub unsafe fn new(wl_display: *mut c_void) -> Self {
|
||||
// Safety: an den Aufrufer weitergereicht
|
||||
let backend =
|
||||
unsafe { wayland_backend::client::Backend::from_foreign_display(wl_display.cast()) };
|
||||
let conn = Connection::from_backend(backend);
|
||||
let queue = conn.new_event_queue();
|
||||
conn.display().get_registry(&queue.handle(), ());
|
||||
let _ = conn.flush();
|
||||
Self {
|
||||
conn,
|
||||
queue,
|
||||
state: State::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verarbeitet eingetroffene Ereignisse; einmal pro Frame aufrufen.
|
||||
pub fn pump(&mut self) {
|
||||
let _ = self.queue.dispatch_pending(&mut self.state);
|
||||
if self.state.text_input.is_none() {
|
||||
if let (Some(manager), Some(seat)) = (&self.state.manager, &self.state.seat) {
|
||||
self.state.text_input =
|
||||
Some(manager.get_text_input(seat, &self.queue.handle(), ()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bittet den Compositor, die Bildschirmtastatur einzublenden. Muss nach dem `enable` von
|
||||
/// winit kommen (also nach `handle_platform_output`).
|
||||
pub fn request(&mut self) {
|
||||
self.pump();
|
||||
if let Some(text_input) = &self.state.text_input {
|
||||
text_input.enable();
|
||||
self.send_surrounding_placeholder();
|
||||
}
|
||||
}
|
||||
|
||||
/// Tastendrücke für das seit dem letzten Aufruf gewünschte Löschen (vor `begin_frame`
|
||||
/// aufrufen und an die egui-Eingabe anhängen)
|
||||
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
|
||||
self.pump();
|
||||
// Den Platzhalter hier nicht neu melden: Die Tastatur löscht bei jeder neuen Meldung
|
||||
// erneut, solange die Rücktaste gedrückt ist – ein kurzes Antippen löschte sonst doppelt.
|
||||
let back = std::mem::take(&mut self.state.delete_back);
|
||||
let forward = std::mem::take(&mut self.state.delete_forward);
|
||||
deletion_key_events(back, forward)
|
||||
}
|
||||
|
||||
fn send_surrounding_placeholder(&self) {
|
||||
if let Some(text_input) = &self.state.text_input {
|
||||
let len = SURROUNDING_PLACEHOLDER.len() as i32;
|
||||
text_input.set_surrounding_text(SURROUNDING_PLACEHOLDER.to_owned(), len, len);
|
||||
text_input.commit();
|
||||
let _ = self.conn.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<wl_registry::WlRegistry, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
registry: &wl_registry::WlRegistry,
|
||||
event: wl_registry::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_registry::Event::Global {
|
||||
name, interface, ..
|
||||
} = event
|
||||
{
|
||||
match interface.as_str() {
|
||||
"wl_seat" if state.seat.is_none() => {
|
||||
state.seat = Some(registry.bind(name, 1, qh, ()));
|
||||
}
|
||||
"zwp_text_input_manager_v3" if state.manager.is_none() => {
|
||||
state.manager = Some(registry.bind(name, 1, qh, ()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delegate_noop!(State: ignore wl_seat::WlSeat);
|
||||
delegate_noop!(State: ZwpTextInputManagerV3);
|
||||
impl Dispatch<ZwpTextInputV3, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &ZwpTextInputV3,
|
||||
event: zwp_text_input_v3::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
// Getippter Text kommt über winit; hier nur das Löschen
|
||||
if let zwp_text_input_v3::Event::DeleteSurroundingText {
|
||||
before_length,
|
||||
after_length,
|
||||
} = event
|
||||
{
|
||||
state.delete_back += before_length;
|
||||
state.delete_forward += after_length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Wayland part of [`super`]: an own text-input object on the window's connection.
|
||||
|
||||
use std::ffi::c_void;
|
||||
|
||||
use wayland_client::{
|
||||
delegate_noop,
|
||||
protocol::{wl_registry, wl_seat},
|
||||
Connection, Dispatch, EventQueue, QueueHandle,
|
||||
};
|
||||
use wayland_protocols::wp::text_input::zv3::client::{
|
||||
zwp_text_input_manager_v3::ZwpTextInputManagerV3,
|
||||
zwp_text_input_v3::{self, ZwpTextInputV3},
|
||||
};
|
||||
|
||||
use super::deletion_key_events;
|
||||
|
||||
/// Platzhalter-Umgebungstext: ein Zeichen vor der Schreibmarke, damit die Rücktaste der
|
||||
/// Bildschirmtastatur immer etwas zu löschen hat (ein Byte = ein Zeichen)
|
||||
const SURROUNDING_PLACEHOLDER: &str = " ";
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
seat: Option<wl_seat::WlSeat>,
|
||||
manager: Option<ZwpTextInputManagerV3>,
|
||||
text_input: Option<ZwpTextInputV3>,
|
||||
/// Von der Bildschirmtastatur gewünschtes Löschen (Zeichen vor/nach der Schreibmarke)
|
||||
delete_back: u32,
|
||||
delete_forward: u32,
|
||||
}
|
||||
|
||||
/// Eigenes text-input-Objekt, mit dem ink-notes die Bildschirmtastatur anfordert
|
||||
pub struct KeyboardRequester {
|
||||
conn: Connection,
|
||||
queue: EventQueue<State>,
|
||||
state: State,
|
||||
}
|
||||
|
||||
impl KeyboardRequester {
|
||||
/// Requester for the window if it runs on Wayland.
|
||||
///
|
||||
/// # Safety
|
||||
/// The window must outlive the requester.
|
||||
pub unsafe fn for_window(window: &winit::window::Window) -> Option<Self> {
|
||||
use winit::raw_window_handle::{HasDisplayHandle, RawDisplayHandle};
|
||||
match window.display_handle().map(|handle| handle.as_raw()) {
|
||||
// Safety: passed on to the caller
|
||||
Ok(RawDisplayHandle::Wayland(handle)) => {
|
||||
Some(unsafe { Self::new(handle.display.as_ptr()) })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hängt sich an die Wayland-Verbindung des Fensters.
|
||||
///
|
||||
/// # Safety
|
||||
/// `wl_display` muss auf die `wl_display` des Fensters zeigen und gültig bleiben, solange
|
||||
/// der `KeyboardRequester` lebt.
|
||||
pub unsafe fn new(wl_display: *mut c_void) -> Self {
|
||||
// Safety: an den Aufrufer weitergereicht
|
||||
let backend =
|
||||
unsafe { wayland_backend::client::Backend::from_foreign_display(wl_display.cast()) };
|
||||
let conn = Connection::from_backend(backend);
|
||||
let queue = conn.new_event_queue();
|
||||
conn.display().get_registry(&queue.handle(), ());
|
||||
let _ = conn.flush();
|
||||
Self {
|
||||
conn,
|
||||
queue,
|
||||
state: State::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verarbeitet eingetroffene Ereignisse; einmal pro Frame aufrufen.
|
||||
pub fn pump(&mut self) {
|
||||
let _ = self.queue.dispatch_pending(&mut self.state);
|
||||
if self.state.text_input.is_none() {
|
||||
if let (Some(manager), Some(seat)) = (&self.state.manager, &self.state.seat) {
|
||||
self.state.text_input =
|
||||
Some(manager.get_text_input(seat, &self.queue.handle(), ()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bittet den Compositor, die Bildschirmtastatur einzublenden. Muss nach dem `enable` von
|
||||
/// winit kommen (also nach `handle_platform_output`).
|
||||
pub fn request(&mut self) {
|
||||
self.pump();
|
||||
if let Some(text_input) = &self.state.text_input {
|
||||
text_input.enable();
|
||||
self.send_surrounding_placeholder();
|
||||
}
|
||||
}
|
||||
|
||||
/// Tastendrücke für das seit dem letzten Aufruf gewünschte Löschen (vor `begin_frame`
|
||||
/// aufrufen und an die egui-Eingabe anhängen)
|
||||
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
|
||||
self.pump();
|
||||
// Den Platzhalter hier nicht neu melden: Die Tastatur löscht bei jeder neuen Meldung
|
||||
// erneut, solange die Rücktaste gedrückt ist – ein kurzes Antippen löschte sonst doppelt.
|
||||
let back = std::mem::take(&mut self.state.delete_back);
|
||||
let forward = std::mem::take(&mut self.state.delete_forward);
|
||||
deletion_key_events(back, forward)
|
||||
}
|
||||
|
||||
fn send_surrounding_placeholder(&self) {
|
||||
if let Some(text_input) = &self.state.text_input {
|
||||
let len = SURROUNDING_PLACEHOLDER.len() as i32;
|
||||
text_input.set_surrounding_text(SURROUNDING_PLACEHOLDER.to_owned(), len, len);
|
||||
text_input.commit();
|
||||
let _ = self.conn.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<wl_registry::WlRegistry, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
registry: &wl_registry::WlRegistry,
|
||||
event: wl_registry::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_registry::Event::Global {
|
||||
name, interface, ..
|
||||
} = event
|
||||
{
|
||||
match interface.as_str() {
|
||||
"wl_seat" if state.seat.is_none() => {
|
||||
state.seat = Some(registry.bind(name, 1, qh, ()));
|
||||
}
|
||||
"zwp_text_input_manager_v3" if state.manager.is_none() => {
|
||||
state.manager = Some(registry.bind(name, 1, qh, ()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delegate_noop!(State: ignore wl_seat::WlSeat);
|
||||
delegate_noop!(State: ZwpTextInputManagerV3);
|
||||
impl Dispatch<ZwpTextInputV3, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &ZwpTextInputV3,
|
||||
event: zwp_text_input_v3::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
// Getippter Text kommt über winit; hier nur das Löschen
|
||||
if let zwp_text_input_v3::Event::DeleteSurroundingText {
|
||||
before_length,
|
||||
after_length,
|
||||
} = event
|
||||
{
|
||||
state.delete_back += before_length;
|
||||
state.delete_forward += after_length;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Folders for notebooks, settings and downloads on each platform (Issues #42, #48).
|
||||
//!
|
||||
//! - Linux: XDG (`~/.local/share/ink-notes`, `~/.config/ink-notes`, `~/.cache/ink-notes`)
|
||||
//! - Windows: `%APPDATA%\ink-notes` for data and settings, `%LOCALAPPDATA%\ink-notes` for
|
||||
//! downloads
|
||||
//! - Android: the app's internal folder, set once at startup with [`set_app_dir`]
|
||||
//!
|
||||
//! The environment is read on every call, so tests can point the folders elsewhere.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Fixed app folder (Android: internal storage of the app)
|
||||
static APP_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// Puts data, settings and downloads below `dir` (Android, where there is no XDG or home).
|
||||
pub fn set_app_dir(dir: PathBuf) {
|
||||
let _ = APP_DIR.set(dir);
|
||||
}
|
||||
|
||||
/// Value of an environment variable, unless empty.
|
||||
fn env_dir(key: &str) -> Option<PathBuf> {
|
||||
std::env::var_os(key)
|
||||
.filter(|v| !v.to_string_lossy().trim().is_empty())
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
/// Home folder of the user.
|
||||
pub fn home_dir() -> Option<PathBuf> {
|
||||
if cfg!(windows) {
|
||||
env_dir("USERPROFILE").or_else(|| env_dir("HOME"))
|
||||
} else {
|
||||
env_dir("HOME")
|
||||
}
|
||||
}
|
||||
|
||||
/// Folder for notebooks and emergency backups.
|
||||
pub fn data_dir() -> Option<PathBuf> {
|
||||
if let Some(dir) = APP_DIR.get() {
|
||||
return Some(dir.clone());
|
||||
}
|
||||
if cfg!(windows) {
|
||||
return env_dir("APPDATA").map(|d| d.join("ink-notes"));
|
||||
}
|
||||
env_dir("XDG_DATA_HOME")
|
||||
.or_else(|| home_dir().map(|h| h.join(".local").join("share")))
|
||||
.map(|d| d.join("ink-notes"))
|
||||
}
|
||||
|
||||
/// Folder for the settings (`ui.json`).
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
if let Some(dir) = APP_DIR.get() {
|
||||
return Some(dir.join("config"));
|
||||
}
|
||||
if cfg!(windows) {
|
||||
return env_dir("APPDATA").map(|d| d.join("ink-notes"));
|
||||
}
|
||||
env_dir("XDG_CONFIG_HOME")
|
||||
.or_else(|| home_dir().map(|h| h.join(".config")))
|
||||
.map(|d| d.join("ink-notes"))
|
||||
}
|
||||
|
||||
/// Folder for downloaded updates.
|
||||
pub fn cache_dir() -> Option<PathBuf> {
|
||||
if let Some(dir) = APP_DIR.get() {
|
||||
return Some(dir.join("cache"));
|
||||
}
|
||||
if cfg!(windows) {
|
||||
return env_dir("LOCALAPPDATA").map(|d| d.join("ink-notes"));
|
||||
}
|
||||
env_dir("XDG_CACHE_HOME")
|
||||
.or_else(|| home_dir().map(|h| h.join(".cache")))
|
||||
.map(|d| d.join("ink-notes"))
|
||||
}
|
||||
+113
@@ -58,6 +58,69 @@ pub fn display_backend_from_env() -> DisplayBackend {
|
||||
)
|
||||
}
|
||||
|
||||
/// Event loop for the desktop; on Linux with the display backend chosen above.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub fn desktop_event_loop() -> Result<winit::event_loop::EventLoop<()>, winit::error::EventLoopError>
|
||||
{
|
||||
#[allow(unused_mut)]
|
||||
let mut builder = winit::event_loop::EventLoopBuilder::new();
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use winit::platform::{wayland::EventLoopBuilderExtWayland, x11::EventLoopBuilderExtX11};
|
||||
match display_backend_from_env() {
|
||||
DisplayBackend::X11 => {
|
||||
eprintln!("[Anzeige] Verwende X11 (unter WSL Standard; übersteuerbar mit INK_NOTES_DISPLAY=wayland)");
|
||||
builder.with_x11();
|
||||
}
|
||||
DisplayBackend::Wayland => {
|
||||
eprintln!("[Anzeige] Verwende Wayland (INK_NOTES_DISPLAY=wayland)");
|
||||
builder.with_wayland();
|
||||
}
|
||||
DisplayBackend::Automatic => {}
|
||||
}
|
||||
}
|
||||
builder.build()
|
||||
}
|
||||
|
||||
/// Gives the window the app id (Wayland: app_id, X11: WM_CLASS), so that dock and task bar
|
||||
/// show the app icon for it.
|
||||
pub fn with_app_id(
|
||||
builder: winit::window::WindowBuilder,
|
||||
#[allow(unused_variables)] app_id: &str,
|
||||
) -> winit::window::WindowBuilder {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let builder = winit::platform::wayland::WindowBuilderExtWayland::with_name(
|
||||
builder,
|
||||
app_id,
|
||||
"ink-notes",
|
||||
);
|
||||
winit::platform::x11::WindowBuilderExtX11::with_name(builder, app_id, "ink-notes")
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
builder
|
||||
}
|
||||
|
||||
/// Ignores touches on the client-side title bar until `until` (palm rejection while the pen
|
||||
/// is near; only Wayland draws its own title bar).
|
||||
pub fn ignore_decoration_touches_until(
|
||||
#[allow(unused_variables)] until: Option<std::time::Instant>,
|
||||
) {
|
||||
#[cfg(target_os = "linux")]
|
||||
winit::platform::wayland::ignore_decoration_touches_until(until);
|
||||
}
|
||||
|
||||
/// Time of a touch event as reported by the compositor (milliseconds, Wayland only).
|
||||
pub fn take_touch_time(
|
||||
#[allow(unused_variables)] id: u64,
|
||||
#[allow(unused_variables)] phase: winit::event::TouchPhase,
|
||||
) -> Option<u32> {
|
||||
#[cfg(target_os = "linux")]
|
||||
return winit::platform::wayland::take_touch_time(id, phase);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
None
|
||||
}
|
||||
|
||||
/// Bringt die Ereignisse eines Tablet-Frames in eine brauchbare Reihenfolge: octotablet meldet
|
||||
/// das Aufsetzen des Stifts (`Down`) vor der Position desselben Frames (`Pose`). Bis dahin
|
||||
/// gälte noch die alte Position – kam der Stift schnell heran (Annähern und Aufsetzen im selben
|
||||
@@ -91,6 +154,56 @@ pub fn down_after_pose<T>(
|
||||
ordered
|
||||
}
|
||||
|
||||
/// System clipboard: arboard on the desktop; on Android not available yet (Issue #42).
|
||||
pub mod clipboard {
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub use arboard::{Clipboard, ImageData};
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub use android::{Clipboard, ImageData};
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod android {
|
||||
use std::borrow::Cow;
|
||||
|
||||
pub struct ImageData<'a> {
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub bytes: Cow<'a, [u8]>,
|
||||
}
|
||||
|
||||
impl ImageData<'_> {
|
||||
pub fn to_owned_img(&self) -> ImageData<'static> {
|
||||
ImageData {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
bytes: Cow::Owned(self.bytes.to_vec()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Clipboard;
|
||||
|
||||
impl Clipboard {
|
||||
pub fn new() -> Result<Self, &'static str> {
|
||||
Err("not available on Android")
|
||||
}
|
||||
|
||||
pub fn get_image(&mut self) -> Result<ImageData<'static>, &'static str> {
|
||||
Err("not available on Android")
|
||||
}
|
||||
|
||||
pub fn get_text(&mut self) -> Result<String, &'static str> {
|
||||
Err("not available on Android")
|
||||
}
|
||||
|
||||
pub fn set_image(&mut self, _image: ImageData) -> Result<(), &'static str> {
|
||||
Err("not available on Android")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+2
-6
@@ -258,13 +258,9 @@ impl Default for UiSettings {
|
||||
}
|
||||
|
||||
impl UiSettings {
|
||||
/// Standardpfad gemäß XDG-Spezifikation.
|
||||
/// Standardpfad gemäß XDG-Spezifikation (see [`crate::paths`] for other systems).
|
||||
pub fn default_path() -> Option<PathBuf> {
|
||||
let base = match std::env::var("XDG_CONFIG_HOME") {
|
||||
Ok(dir) if !dir.trim().is_empty() => PathBuf::from(dir),
|
||||
_ => PathBuf::from(std::env::var("HOME").ok()?).join(".config"),
|
||||
};
|
||||
Some(base.join("ink-notes").join("ui.json"))
|
||||
crate::paths::config_dir().map(|d| d.join("ui.json"))
|
||||
}
|
||||
|
||||
/// Lädt die Einstellungen; bei fehlender oder beschädigter Datei gelten Standardwerte.
|
||||
|
||||
+8
-16
@@ -994,21 +994,9 @@ fn copy_dir(from: &Path, to: &Path) -> io::Result<()> {
|
||||
|
||||
/// Gibt das Standard-Verzeichnis für Notizbücher zurück (gemäß XDG-Spezifikation).
|
||||
pub fn default_storage_dir() -> PathBuf {
|
||||
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
||||
if !xdg.is_empty() {
|
||||
return PathBuf::from(xdg).join("ink-notes").join("notebooks");
|
||||
}
|
||||
}
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
if !home.is_empty() {
|
||||
return PathBuf::from(home)
|
||||
.join(".local")
|
||||
.join("share")
|
||||
.join("ink-notes")
|
||||
.join("notebooks");
|
||||
}
|
||||
}
|
||||
PathBuf::from("./data/notebooks")
|
||||
crate::paths::data_dir()
|
||||
.map(|d| d.join("notebooks"))
|
||||
.unwrap_or_else(|| PathBuf::from("./data/notebooks"))
|
||||
}
|
||||
|
||||
/// Status eines Notizbuchs in der Dokumentenliste.
|
||||
@@ -1130,7 +1118,11 @@ impl NotebookLock {
|
||||
}
|
||||
Err(e) => {
|
||||
let kind = e.kind();
|
||||
if kind == io::ErrorKind::WouldBlock || e.raw_os_error() == Some(11) {
|
||||
// Windows reports a held lock as ERROR_LOCK_VIOLATION
|
||||
if kind == io::ErrorKind::WouldBlock
|
||||
|| e.raw_os_error() == Some(11)
|
||||
|| e.raw_os_error() == fs2::lock_contended_error().raw_os_error()
|
||||
{
|
||||
Err(StorageError::AlreadyLocked(t!(
|
||||
"Notizbuch '{path}' wird bereits von einer anderen Instanz verwendet",
|
||||
path = notebook_dir.display()
|
||||
|
||||
@@ -5076,6 +5076,7 @@ pub fn render_ui(
|
||||
let is_recommended = match updater.runtime() {
|
||||
crate::updater::RuntimeEnvironment::AppImage { .. } => is_appimage,
|
||||
crate::updater::RuntimeEnvironment::DebPackage { .. } => is_deb,
|
||||
crate::updater::RuntimeEnvironment::WindowsExe { .. } => asset.name.ends_with(".exe"),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
@@ -5141,6 +5142,7 @@ pub fn render_ui(
|
||||
crate::updater::RuntimeEnvironment::DebPackage { .. } => t!("📦 Paket mit Software-Center öffnen / installieren"),
|
||||
crate::updater::RuntimeEnvironment::PortableOrDev { .. }
|
||||
| crate::updater::RuntimeEnvironment::Flatpak => t!("📂 Im Dateimanager anzeigen"),
|
||||
crate::updater::RuntimeEnvironment::WindowsExe { .. } => t!("🔄 Update installieren"),
|
||||
};
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
|
||||
+80
-19
@@ -121,6 +121,8 @@ pub enum RuntimeEnvironment {
|
||||
/// Als Flatpak installiert: Updates kommen über die Software-Verwaltung, ink-notes lädt
|
||||
/// selbst nichts herunter (Vorgabe von Flathub)
|
||||
Flatpak,
|
||||
/// Portable `.exe` on Windows (Issue #48), replaced in place like the AppImage
|
||||
WindowsExe { exe_path: PathBuf },
|
||||
}
|
||||
|
||||
impl RuntimeEnvironment {
|
||||
@@ -132,8 +134,13 @@ impl RuntimeEnvironment {
|
||||
) {
|
||||
return RuntimeEnvironment::Flatpak;
|
||||
}
|
||||
let appimage_var = std::env::var("APPIMAGE").ok();
|
||||
let current_exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("ink-notes"));
|
||||
if cfg!(windows) {
|
||||
return RuntimeEnvironment::WindowsExe {
|
||||
exe_path: current_exe,
|
||||
};
|
||||
}
|
||||
let appimage_var = std::env::var("APPIMAGE").ok();
|
||||
Self::detect_from(appimage_var.as_deref(), ¤t_exe)
|
||||
}
|
||||
|
||||
@@ -166,6 +173,7 @@ impl RuntimeEnvironment {
|
||||
RuntimeEnvironment::DebPackage { .. } => t!("System-Paket (.deb)"),
|
||||
RuntimeEnvironment::PortableOrDev { .. } => t!("Entwicklung / Lokal"),
|
||||
RuntimeEnvironment::Flatpak => "Flatpak",
|
||||
RuntimeEnvironment::WindowsExe { .. } => "Windows",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,18 +218,9 @@ pub fn sanitize_asset_name(raw_name: &str) -> Result<String, String> {
|
||||
|
||||
/// Liefert das isolierte Cache-Verzeichnis für Downloads pro Nutzer mit Rechten 0700.
|
||||
pub fn get_updates_cache_dir() -> Result<PathBuf, String> {
|
||||
let base = if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
|
||||
let trimmed = xdg.trim();
|
||||
if !trimmed.is_empty() {
|
||||
PathBuf::from(trimmed)
|
||||
} else {
|
||||
default_user_cache_dir()?
|
||||
}
|
||||
} else {
|
||||
default_user_cache_dir()?
|
||||
};
|
||||
|
||||
let dir = base.join("ink-notes").join("updates");
|
||||
let dir = crate::paths::cache_dir()
|
||||
.ok_or_else(|| t!("HOME-Umgebungsvariable ist nicht gesetzt.").to_string())?
|
||||
.join("updates");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| {
|
||||
t!(
|
||||
"Konnte Update-Cache-Verzeichnis nicht anlegen ({path}): {e}",
|
||||
@@ -239,12 +238,6 @@ pub fn get_updates_cache_dir() -> Result<PathBuf, String> {
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn default_user_cache_dir() -> Result<PathBuf, String> {
|
||||
let home = std::env::var("HOME")
|
||||
.map_err(|_| t!("HOME-Umgebungsvariable ist nicht gesetzt.").to_string())?;
|
||||
Ok(PathBuf::from(home).join(".cache"))
|
||||
}
|
||||
|
||||
/// Löscht alte temporäre Downloads aus dem Cache-Verzeichnis.
|
||||
pub fn cleanup_old_updates() {
|
||||
if let Ok(dir) = get_updates_cache_dir() {
|
||||
@@ -551,6 +544,48 @@ fn http_get_with_auth_guard(
|
||||
Err(t!("Zu viele Weiterleitungen (Redirect-Schleife abgebrochen).").to_string())
|
||||
}
|
||||
|
||||
/// Replaces the running `.exe` with the downloaded one (Windows). A running program can't be
|
||||
/// overwritten but can be renamed: the old one becomes `<name>.bak`, the new one takes its
|
||||
/// place and starts with the next launch.
|
||||
pub fn replace_running_exe(downloaded: &Path, exe_path: &Path) -> Result<(), String> {
|
||||
let file_name = exe_path
|
||||
.file_name()
|
||||
.ok_or_else(|| t!("Ungültiger Programmpfad.").to_string())?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let dir = exe_path
|
||||
.parent()
|
||||
.ok_or_else(|| t!("Ungültiger Programmpfad.").to_string())?;
|
||||
let new_file = dir.join(format!("{file_name}.new"));
|
||||
let backup = dir.join(format!("{file_name}.bak"));
|
||||
|
||||
std::fs::copy(downloaded, &new_file).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&new_file);
|
||||
t!("Konnte temporäre Update-Datei nicht schreiben: {e}", e = e)
|
||||
})?;
|
||||
// A backup of an earlier update is no longer running
|
||||
let _ = std::fs::remove_file(&backup);
|
||||
if let Err(e) = std::fs::rename(exe_path, &backup) {
|
||||
let _ = std::fs::remove_file(&new_file);
|
||||
return Err(t!(
|
||||
"Konnte Backup ({path}) nicht anlegen: {e}. Update abgebrochen, altes Programm unverändert.",
|
||||
path = backup.display(),
|
||||
e = e
|
||||
));
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&new_file, exe_path) {
|
||||
// Put the old program back
|
||||
let _ = std::fs::rename(&backup, exe_path);
|
||||
let _ = std::fs::remove_file(&new_file);
|
||||
return Err(t!(
|
||||
"Konnte Programm nicht ersetzen: {e}. Altes Programm bleibt unverändert.",
|
||||
e = e
|
||||
));
|
||||
}
|
||||
let _ = std::fs::remove_file(downloaded);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Asynchroner Controller für Update-Prüfung und Asset-Download.
|
||||
pub struct UpdaterController {
|
||||
config: UpdaterConfig,
|
||||
@@ -915,6 +950,7 @@ impl UpdaterController {
|
||||
"Die Flatpak-Version wird über die Software-Verwaltung aktualisiert."
|
||||
)
|
||||
.to_string()),
|
||||
RuntimeEnvironment::WindowsExe { exe_path } => replace_running_exe(file_path, exe_path),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1102,6 +1138,31 @@ dcdeb7ac87d9e6cb8022e5a71bc7f2c56d779b4fce4efdd5bae5be3b0214884a ink-notes_0.1.
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_replace_running_exe() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let exe = dir.path().join("ink-notes.exe");
|
||||
let downloaded = dir.path().join("ink-notes_9.9.9_x86_64.exe");
|
||||
std::fs::write(&exe, b"old").unwrap();
|
||||
std::fs::write(&downloaded, b"new").unwrap();
|
||||
replace_running_exe(&downloaded, &exe).unwrap();
|
||||
assert_eq!(std::fs::read(&exe).unwrap(), b"new");
|
||||
assert_eq!(
|
||||
std::fs::read(dir.path().join("ink-notes.exe.bak")).unwrap(),
|
||||
b"old"
|
||||
);
|
||||
assert!(!downloaded.exists());
|
||||
|
||||
// Again: the backup of the first update is replaced
|
||||
std::fs::write(&downloaded, b"newer").unwrap();
|
||||
replace_running_exe(&downloaded, &exe).unwrap();
|
||||
assert_eq!(std::fs::read(&exe).unwrap(), b"newer");
|
||||
assert_eq!(
|
||||
std::fs::read(dir.path().join("ink-notes.exe.bak")).unwrap(),
|
||||
b"new"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flatpak_detection_disables_self_update() {
|
||||
assert!(is_flatpak(Some("eu.pansi.ink-notes"), false));
|
||||
|
||||
Reference in new issue
Block a user