feat: Windows build and Android groundwork (Issues #48, #42)

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:
haraldandClaude Opus 5.5 committed 2026-10-27 15:13:37 +01:00
1 parent beb780b420
commit e5a8601f87
26 files changed
+762 -270

No files matched your search

+57 -43
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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";
+5
View File
@@ -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
View File
@@ -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}");
}
}
+8
View File
@@ -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
View File
@@ -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::*;
+163
View File
@@ -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;
}
}
}
+74
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+2
View File
@@ -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
View File
@@ -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(), &current_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));