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"));
}