release: v0.1.5 — Event Sourcing, Flatpak build fixes, icons
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build packages (push) Blocked by required conditions

This commit is contained in:
Khoa Vo 2026-06-28 21:20:19 +07:00
parent 769d84aa80
commit a714dca0be
35 changed files with 652 additions and 193 deletions

1
.gitignore vendored
View file

@ -15,4 +15,5 @@ status
vietc-xrecord
packaging/flatpak/build-dir
packaging/flatpak/vietc-repo
packaging/flatpak/repo
packaging/flatpak/VietPlus-*

View file

@ -1,5 +1,36 @@
# Changelog
## v0.1.5 (2026-06-28)
### Event Sourcing (privacy-safe architecture)
- **EventStore** replaces `Vec<char>` keystroke history — typed `InputEvent`s (`KeyTyped`, `Backspace`, `Flush`, `Paste`) with `push/pop/clear/raw_keystrokes/pattern_hash`
- **`Engine::replay_events()`** — stateless replay through fresh BambooEngine (replaces `replay_keystrokes()`)
- **`Engine::replay_events_to_commands()`** — computes diff commands (`Type`, `Backspace`) comparing expected vs screen output
- **`EventStore::pattern_hash()`** — sha256 of event type sequence; privacy-safe pattern detection without text recovery
- **Daemon updated** — all `keystroke_history` references migrated to `event_store`; `replay_and_inject()`, `replay_backspace()`, `word_to_commit()`, `replay_reset()` use new Event Sourcing API
### Flatpak Build Fixes
- **Fixed SDK/RUNTIME swap**: `flatpak build-init` arg order is `SDK` then `RUNTIME`; previous `org.gnome.Platform` as SDK meant `/usr/lib/sdk/` was never mounted
- **Rust SDK extension** now auto-mounts at `/usr/lib/sdk/rust-stable/` — no symlinks or file copies needed
- **Icons**: renamed to `io.github.vietc.VietPlus.*` prefix (Flatpak export requires app ID prefix for all icon files)
- **Desktop file**: removed unregistered `InputMethod` category
- **Tray**: `icon_name()` returns Flatpak-prefixed names when running inside Flatpak sandbox (detected via `/app/bin/vietc-daemon`); `icon_pixmap()` programmatic fallback unchanged
- **Bundle**: `VietPlus-0.1.5.flatpak` (46 MB, runtime `org.gnome.Platform//50`)
### Documentation
- `packaging/flatpak/FLATPAK_BUILD.md` — detailed build instructions (prerequisites, manual step-by-step, installation)
- `RELEASE_CHECKLIST.md` — step-by-step release process (bump version, build, test, push, create release)
### Licenses
- MIT license headers (`// SPDX-License-Identifier: MIT`) on all 22 `.rs` files across 6 crates
### Icons
- `packaging/icons/vietc.svg` — app icon (keyboard + VN badge)
- `packaging/icons/vietc-vn.svg` — tray icon (red VN)
- `packaging/icons/vietc-en.svg` — tray icon (gray EN)
---
## v0.1.4 (2026-06-28)
### Flatpak Packaging

View file

@ -2,8 +2,9 @@
<img src="https://img.shields.io/badge/Platform-Linux-blue?style=for-the-badge" alt="Platform">
<img src="https://img.shields.io/badge/Language-Rust-orange?style=for-the-badge" alt="Rust">
<img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License">
<img src="https://img.shields.io/badge/Version-0.1.4-purple?style=for-the-badge" alt="Version">
<img src="https://img.shields.io/badge/Version-0.1.5-purple?style=for-the-badge" alt="Version">
<img src="https://img.shields.io/badge/Tests-106_passing-brightgreen?style=for-the-badge" alt="Tests">
<img src="https://img.shields.io/badge/Event_Sourcing-✓-blueviolet?style=for-the-badge" alt="Event Sourcing">
</p>
<h1 align="center">
@ -94,31 +95,32 @@ Physical Keyboard
and renders Vietnamese text on screen
```
### The Backspace-Replay Pattern
### Event Sourcing + Backspace-Replay
This is Viet+'s core innovation. Traditional IMEs track state incrementally — each keystroke updates an internal buffer. But this buffer can **desync** from what's actually on screen (due to focus changes, external pastes, etc.).
Viet+ solves this by **never tracking incremental state**:
Viet+ uses **Event Sourcing**: every input action is recorded as a typed `InputEvent` (`KeyTyped`, `Backspace`, `Flush`, `Paste`) in an `EventStore`. On every keystroke, the entire event history is **replayed from scratch** through a fresh engine to compute the correct diff — no incremental state to desync.
```
Traditional IME:
keystroke → update buffer → emit event → hope it matches screen
Viet+ (Backspace-Replay):
keystroke → add to history → replay ALL history in fresh engine → compute diff
Viet+ (Event Sourcing):
keystroke → append InputEvent → replay ALL events in fresh engine → compute diff
```
On every keystroke:
1. The keystroke is appended to `keystroke_history`
1. The keystroke is appended as an `InputEvent` to the `EventStore`
2. A **brand new** `Engine` is created
3. The **entire** history is replayed through it
3. The **entire** event history is replayed through it via `Engine::replay_events()`
4. The engine's buffer is the **correct** screen output
5. Viet+ computes the diff: how many backspaces to erase old text, what new text to type
5. Viet+ computes the diff: `Engine::replay_events_to_commands()` returns Type/Backspace commands
This means:
- **Zero state desync** — always recomputed from scratch
- **Self-healing** — if anything goes wrong, the next keystroke fixes it
- **Privacy-safe**`EventStore::pattern_hash()` provides a sha256 of the event type sequence for pattern detection without any ability to recover original text
- **Simple** — no complex state tracking or synchronization
---
@ -128,7 +130,8 @@ This means:
```
vietc/
├── engine/ # Vietnamese composition engine (bamboo-core Rust port)
│ ├── engine.rs # Orchestrator + replay_keystrokes()
│ ├── engine.rs # Orchestrator + replay_events(), replay_events_to_commands()
│ ├── event.rs # Event Sourcing: InputEvent, EventStore, Command
│ ├── bamboo.rs # Bamboo engine: transformation model, composition, tone placement
│ ├── input_method.rs # Telex/VNI rule definitions
│ └── spelling.rs # Vietnamese syllable validation
@ -280,10 +283,18 @@ Includes daemon + CLI + system tray + uinput daemon. Sandboxed — no system lib
```bash
git clone https://github.com/vndangkhoa/vietc.git
cd vietc/packaging/flatpak
bash build-flatpak.sh
bash build-flatpak.sh [version]
```
Requires Flatpak runtime `org.gnome.Platform//50` and Rust SDK extension (installed automatically).
Requires Flatpak runtimes: `org.gnome.Platform//50`, `org.gnome.Sdk//50`, `org.freedesktop.Sdk.Extension.rust-stable//25.08`
```bash
flatpak install --user flathub org.gnome.Platform//50
flatpak install --user flathub org.gnome.Sdk//50
flatpak install --user flathub org.freedesktop.Sdk.Extension.rust-stable//25.08
```
See `packaging/flatpak/FLATPAK_BUILD.md` for detailed build instructions.
---

88
RELEASE_CHECKLIST.md Normal file
View file

@ -0,0 +1,88 @@
# Release Checklist
## When to release
- New feature or bugfix that should be distributed to users
- Flatpak build changes validated
- All tests passing (`cargo test`)
---
## Step-by-step
### 1. Bump version
Update version in:
- `daemon/Cargo.toml`
- `cli/Cargo.toml`
- `engine/Cargo.toml`
- `protocol/Cargo.toml`
- `ui/Cargo.toml`
- `uinputd/Cargo.toml`
- `README.md` version badge
### 2. Update CHANGELOG.md
Add a new entry under the version heading:
```markdown
## vX.Y.Z (YYYY-MM-DD)
### Added
- new features...
### Fixed
- bug fixes...
### Changed
- behavior changes...
```
### 3. Build the Flatpak
```bash
cd packaging/flatpak
bash build-flatpak.sh X.Y.Z
```
Verify the bundle was created:
```bash
ls -lh VietPlus-X.Y.Z.flatpak
```
### 4. Test the Flatpak
```bash
flatpak install --user --bundle VietPlus-X.Y.Z.flatpak
flatpak run io.github.vietc.VietPlus
```
### 5. Commit and push
```bash
git add -A
git commit -m "release: vX.Y.Z — <summary>"
git push origin main
```
### 6. Create a release on Forgejo/GitHub
Attach the Flatpak bundle (`VietPlus-X.Y.Z.flatpak`) as a release asset.
```bash
# Using forgejo-release (if configured)
# Or manually upload via the web UI
```
---
## Quick command
```bash
VERSION=X.Y.Z && \
sed -i "s/^version = .*/version = \"$VERSION\"/" \
daemon/Cargo.toml cli/Cargo.toml engine/Cargo.toml \
protocol/Cargo.toml ui/Cargo.toml uinputd/Cargo.toml && \
sed -i "s/Version-[0-9.]*-purple/Version-$VERSION-purple/" README.md && \
echo "Version bumped to $VERSION"
```

View file

@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
use std::io::{self, Write};
use vietc_engine::{Engine, EngineEvent, InputMethod};

View file

@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
use std::collections::HashMap;
use std::fs;
use std::process::Command;

View file

@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;

View file

@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
use std::process::Command;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]

View file

@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
@ -6,7 +7,7 @@ use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use vietc_engine::{Engine, EngineEvent, InputMethod};
use vietc_engine::{Engine, EngineEvent, EventStore, InputEvent, InputMethod};
/// Pin current thread to performance cores (0-3) and boost priority.
/// Inspired by VMK's approach to minimize input latency on Intel hybrid CPUs.
@ -110,10 +111,11 @@ struct Daemon {
app_state: AppStateManager,
engine_enabled: Arc<AtomicBool>,
grab_enabled: bool,
/// Backspace-Replay: all keystrokes in the current word being composed.
/// On each keypress, we replay the entire history through a fresh engine
/// to compute the correct screen output, eliminating state desync.
keystroke_history: Vec<char>,
/// Event Store: append-only log of typed input events.
/// On each input, we replay the entire event log through a fresh engine
/// to compute the expected screen output, eliminating state desync.
/// KHÔNG lưu nội dung nhạy cảm — chỉ lưu event sequence.
event_store: EventStore,
/// What's currently displayed on screen for the current word.
/// Used to calculate how many backspaces we need before retyping.
screen_output: String,
@ -154,7 +156,7 @@ impl Daemon {
config_modified,
app_state,
engine_enabled,
keystroke_history: Vec::new(),
event_store: EventStore::new(),
screen_output: String::new(),
}
}
@ -279,11 +281,16 @@ impl Daemon {
self.app_state.is_current_app_bypassed()
}
/// Backspace-Replay: replay the entire keystroke history through a fresh
/// engine, compute what should be on screen, and return the commands
/// Event Sourcing: replay the entire event store through a fresh engine,
/// compute what should be on screen, and return the commands
/// (backspaces to erase old + new text to type).
/// KHÔNG đọc DOM, chỉ dựa trên event sequence.
fn replay_and_inject(&mut self, ch: char) -> Vec<OutputCommand> {
let mut commands = Vec::new();
let method = match self.config.input_method.as_str() {
"vni" => InputMethod::Vni,
_ => InputMethod::Telex,
};
// Flush characters: commit the current word and type the flush char.
// Only backspace + retype when auto-restore actually CHANGES the word
@ -291,6 +298,7 @@ impl Daemon {
// already correctly on screen, so retyping it would eat the spacing and
// shift the finished word left.
if is_flush_char(ch) {
self.event_store.push(InputEvent::Flush(ch));
let to_commit = self.word_to_commit();
if !self.screen_output.is_empty() && to_commit != self.screen_output {
let backspaces = self.screen_output.chars().count();
@ -299,37 +307,29 @@ impl Daemon {
}
// Type the flush character itself
commands.push(OutputCommand::Type(ch.to_string()));
self.keystroke_history.clear();
self.event_store.clear();
self.screen_output.clear();
return commands;
}
// Add the new keystroke to history
self.keystroke_history.push(ch);
// Record the typed key as an event
self.event_store.push(InputEvent::KeyTyped(ch));
// Replay through fresh engine
let method = match self.config.input_method.as_str() {
"vni" => InputMethod::Vni,
_ => InputMethod::Telex,
};
let (new_output, did_flush) = Engine::replay_keystrokes(
// Replay entire event log through fresh engine
let (new_output, did_flush) = Engine::replay_events(
method,
&self.config.macros,
&self.keystroke_history,
&self.event_store,
);
if did_flush {
// Engine flushed a word. Only backspace + retype when auto-restore
// actually CHANGES the word; otherwise the composed word is already
// correct on screen and retyping it eats spacing and shifts the
// finished word left.
let to_commit = self.word_to_commit();
if !self.screen_output.is_empty() && to_commit != self.screen_output {
let backspaces = self.screen_output.chars().count();
commands.push(OutputCommand::Backspace(backspaces));
commands.push(OutputCommand::Type(to_commit));
}
self.keystroke_history.clear();
self.event_store.clear();
self.screen_output.clear();
return commands;
}
@ -348,31 +348,43 @@ impl Daemon {
commands
}
/// Backspace-Replay: pop from history, replay, and return commands to fix screen.
/// Event Sourcing: pop last event, replay, and return commands to fix screen.
fn replay_backspace(&mut self) -> Vec<OutputCommand> {
let mut commands = Vec::new();
let method = match self.config.input_method.as_str() {
"vni" => InputMethod::Vni,
_ => InputMethod::Telex,
};
if self.keystroke_history.is_empty() {
if self.event_store.is_empty() {
// Nothing in history — just forward the backspace
commands.push(OutputCommand::Backspace(1));
return commands;
}
// Remove last keystroke from history
self.keystroke_history.pop();
// Record backspace event
self.event_store.push(InputEvent::Backspace);
// Remove the last key-typed event for replay (unless it was already a backspace)
match self.event_store.pop() {
Some(InputEvent::Backspace) => {
// Pop again to remove the preceding event
self.event_store.pop();
}
Some(_) => {
// Already popped the last event (KeyTyped or Flush)
}
None => {}
}
// Replay through fresh engine
let method = match self.config.input_method.as_str() {
"vni" => InputMethod::Vni,
_ => InputMethod::Telex,
};
let (new_output, _) = if self.keystroke_history.is_empty() {
let (new_output, _) = if self.event_store.is_empty() {
(String::new(), false)
} else {
Engine::replay_keystrokes(
Engine::replay_events(
method,
&self.config.macros,
&self.keystroke_history,
&self.event_store,
)
};
@ -394,7 +406,7 @@ impl Daemon {
/// word is English / not valid Vietnamese — the raw keystrokes typed.
fn word_to_commit(&self) -> String {
if self.config.auto_restore.enabled {
let raw: String = self.keystroke_history.iter().collect();
let raw = self.event_store.raw_keystrokes();
if Engine::should_restore_word(&self.screen_output, &raw) {
return raw;
}
@ -404,7 +416,7 @@ impl Daemon {
/// Reset the replay state (on flush, focus loss, modifier key, etc.)
fn replay_reset(&mut self) {
self.keystroke_history.clear();
self.event_store.clear();
self.screen_output.clear();
}
@ -731,7 +743,7 @@ fn run_with_x11(
pressed_keys.remove(&event.keycode);
SKIP_RECORD_EVENTS.store(true, Ordering::Relaxed);
execute_commands(&*injector, &commands, true);
if daemon.keystroke_history.is_empty() && commands.is_empty() {
if daemon.event_store.is_empty() && commands.is_empty() {
let _ = injector.send_backspace();
}
}

View file

@ -7,6 +7,7 @@ description = "Viet+ Vietnamese IME Core Engine"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
[dev-dependencies]
insta = { version = "1.34", features = ["yaml"] }

View file

@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
use crate::input_method::{InputMethod, InputMethodRules, get_rules};
use std::collections::HashMap;

View file

@ -1,5 +1,7 @@
// SPDX-License-Identifier: MIT
use crate::bamboo::BambooEngine;
use crate::english::EnglishDict;
use crate::event::{Command, EventStore};
use crate::input_method::InputMethod;
use std::collections::HashMap;
use std::sync::OnceLock;
@ -148,6 +150,88 @@ impl Engine {
(if did_flush { String::new() } else { last_output }, did_flush)
}
/// Replay events through a fresh engine, returning (expected_output, did_flush).
/// This is the Event Sourcing equivalent of replay_keystrokes.
pub fn replay_events(
method: InputMethod,
macros: &HashMap<String, String>,
events: &EventStore,
) -> (String, bool) {
let mut engine = Engine::new(method);
for (shortcut, expansion) in macros {
engine.add_macro(shortcut.clone(), expansion.clone());
}
let mut last_output = String::new();
let mut composing = String::new();
for event in events.iter() {
match event {
crate::event::InputEvent::KeyTyped(ch) => {
if let Some(out) = engine.bamboo.process_key(*ch) {
composing = out.clone();
last_output = out;
} else {
composing = engine.bamboo.get_output();
last_output = composing.clone();
}
}
crate::event::InputEvent::Backspace => {
let _ = engine.bamboo.pop_last();
composing = engine.bamboo.get_output();
last_output = composing.clone();
}
crate::event::InputEvent::Flush(_) => {
if !composing.is_empty() {
last_output = composing.clone();
}
composing.clear();
engine.bamboo.reset();
}
crate::event::InputEvent::Paste(text) => {
for ch in text.chars() {
if let Some(out) = engine.bamboo.process_key(ch) {
composing = out;
}
}
last_output = composing.clone();
}
}
}
let output = engine.bamboo.get_output();
let output_is_empty = output.is_empty();
if !output.is_empty() {
last_output = output;
}
let did_flush = output_is_empty && composing.is_empty();
(if did_flush { String::new() } else { last_output }, did_flush)
}
/// Event Sourcing + Command Pattern: replay events and return diff commands.
/// Compares expected output against screen_output and generates backspace/type commands.
pub fn replay_events_to_commands(
method: InputMethod,
macros: &HashMap<String, String>,
events: &EventStore,
screen_output: &str,
) -> Vec<Command> {
let (new_output, _) = Engine::replay_events(method, macros, events);
let mut commands = Vec::new();
if new_output != screen_output {
let backspaces = screen_output.chars().count();
if backspaces > 0 {
commands.push(Command::Backspace(backspaces));
}
if !new_output.is_empty() {
commands.push(Command::Type(new_output));
}
}
commands
}
pub fn update_with_pasted_text(&mut self, text: &str) {
self.raw_buffer.clear();
self.raw_buffer.push_str(text);

View file

@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
use std::collections::HashSet;
pub struct EnglishDict {

104
engine/src/event.rs Normal file
View file

@ -0,0 +1,104 @@
// SPDX-License-Identifier: MIT
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
/// Typed input event - the core of Event Sourcing.
/// KHÔNG lưu nội dung nhạy cảm, chỉ lưu event sequence.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum InputEvent {
/// A character key was typed
KeyTyped(char),
/// Backspace was pressed
Backspace,
/// A flush character (space, punctuation, enter, tab)
Flush(char),
/// Text was pasted
Paste(String),
}
/// Append-only event store.
/// Source of truth for all user input.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventStore {
events: Vec<InputEvent>,
}
impl EventStore {
pub fn new() -> Self {
Self { events: Vec::new() }
}
pub fn push(&mut self, event: InputEvent) {
self.events.push(event);
}
pub fn pop(&mut self) -> Option<InputEvent> {
self.events.pop()
}
pub fn clear(&mut self) {
self.events.clear();
}
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
pub fn len(&self) -> usize {
self.events.len()
}
pub fn iter(&self) -> impl Iterator<Item = &InputEvent> {
self.events.iter()
}
pub fn as_slice(&self) -> &[InputEvent] {
&self.events
}
/// Extract raw keystrokes from event log (for auto-restore comparison).
/// Only reconstructs the literal characters typed, excluding backspaces.
pub fn raw_keystrokes(&self) -> String {
let mut s = String::new();
for event in &self.events {
match event {
InputEvent::KeyTyped(c) => s.push(*c),
InputEvent::Backspace => { s.pop(); }
InputEvent::Flush(_) => {}
InputEvent::Paste(text) => s.push_str(text),
}
}
s
}
/// Hash the event type sequence (not content) for privacy-safe pattern detection.
/// Output: sha256 hex of event type characters (K=KeyTyped, B=Backspace, F=Flush, P=Paste).
/// Không thể recover text gốc — chỉ biết "có X events với pattern Y".
pub fn pattern_hash(&self) -> String {
let types: String = self.events.iter().map(|e| match e {
InputEvent::KeyTyped(_) => 'K',
InputEvent::Backspace => 'B',
InputEvent::Flush(_) => 'F',
InputEvent::Paste(_) => 'P',
}).collect();
let mut hasher = Sha256::new();
hasher.update(types.as_bytes());
format!("{:x}", hasher.finalize())
}
}
impl Default for EventStore {
fn default() -> Self {
Self::new()
}
}
/// Formalized output commands (Command Pattern).
/// Chỉ chứa diff instruction, không chứa text nhạy cảm.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
/// Type a string of characters
Type(String),
/// Backspace N times
Backspace(usize),
}

View file

@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]

View file

@ -1,6 +1,8 @@
// SPDX-License-Identifier: MIT
mod bamboo;
mod engine;
mod english;
pub mod event;
mod input_method;
pub mod spelling;
@ -9,4 +11,5 @@ mod tests;
pub use engine::Engine;
pub use engine::EngineEvent;
pub use event::{Command, EventStore, InputEvent};
pub use input_method::InputMethod;

View file

@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
const FIRST_CONSONANT_SEQS: &[&str] = &[
"b d đ g gh m n nh p ph r s t tr v z",
"c h k kh qu th",

View file

@ -0,0 +1,112 @@
# Building the Viet+ Flatpak
## Prerequisites
- Flatpak installed with Flathub remote configured
- `org.gnome.Platform//50` runtime installed
- `org.gnome.Sdk//50` SDK installed
- `org.freedesktop.Sdk.Extension.rust-stable//25.08` installed
### Install dependencies
```bash
flatpak install --user flathub org.gnome.Platform//50
flatpak install --user flathub org.gnome.Sdk//50
flatpak install --user flathub org.freedesktop.Sdk.Extension.rust-stable//25.08
```
---
## Method 1: Quick build script
```bash
cd packaging/flatpak
bash build-flatpak.sh [version]
# e.g. bash build-flatpak.sh 0.1.5
```
Output: `packaging/flatpak/VietPlus-<version>.flatpak`
---
## Method 2: Manual step-by-step
```bash
cd packaging/flatpak
# 1. Clean previous artifacts
rm -rf build-dir repo VietPlus-*.flatpak
# 2. Initialize build directory
# NOTE: arg order is flatpak build-init DIR APPNAME SDK RUNTIME
flatpak build-init build-dir io.github.vietc.VietPlus \
org.gnome.Sdk//50 org.gnome.Platform//50
# 3. Copy source code
mkdir -p build-dir/files/src/vietc
rsync -a /path/to/vietc/ build-dir/files/src/vietc/ --exclude=target --exclude=.git
# 4. Build Rust binaries
flatpak build --share=network build-dir sh -c '
export PATH=/usr/lib/sdk/rust-stable/bin:$PATH
export CARGO_HOME=/app/cargo
cd /app/src/vietc
cargo build --release -p vietc-daemon -p vietc-cli -p vietc-uinputd
'
# 5. Install binaries and icons
flatpak build build-dir sh -c '
install -Dm755 /app/src/vietc/target/release/vietc /app/bin/vietc-daemon
install -Dm755 /app/src/vietc/target/release/vietc-cli /app/bin/vietc-cli
install -Dm755 /app/src/vietc/target/release/vietc-uinputd /app/bin/vietc-uinputd
install -Dm644 /app/src/vietc/packaging/icons/vietc.svg \
/app/share/icons/hicolor/scalable/apps/io.github.vietc.VietPlus.svg
install -Dm644 /app/src/vietc/packaging/icons/vietc-vn.svg \
/app/share/icons/hicolor/scalable/apps/io.github.vietc.VietPlus.vietc-vn.svg
install -Dm644 /app/src/vietc/packaging/icons/vietc-en.svg \
/app/share/icons/hicolor/scalable/apps/io.github.vietc.VietPlus.vietc-en.svg
'
# 6. Finish (set permissions + command)
flatpak build-finish build-dir \
--socket=x11 \
--socket=wayland \
--filesystem=home \
--share=ipc \
--talk-name=org.freedesktop.Notifications \
--talk-name=org.a11y.Bus \
--command=vietc-daemon
# 7. Export to local repo
flatpak build-export repo build-dir
# 8. Create bundle
flatpak build-bundle repo VietPlus-0.1.5.flatpak io.github.vietc.VietPlus
```
---
## Installation
```bash
# From bundle
flatpak install --user --bundle VietPlus-0.1.5.flatpak
# From local repo
flatpak --user remote-add --no-gpg-verify vietc-repo repo
flatpak --user install vietc-repo io.github.vietc.VietPlus
# Run
flatpak run io.github.vietc.VietPlus
```
---
## Key Notes
- **SDK/RUNTIME order**: `flatpak build-init` takes `SDK` first, then `RUNTIME` (counterintuitive but important — getting this wrong means `/usr/lib/sdk/` won't be mounted)
- **Rust SDK**: must be installed as `org.freedesktop.Sdk.Extension.rust-stable//25.08`; it mounts automatically at `/usr/lib/sdk/rust-stable/`
- **Icons**: all icon files in Flatpak must be prefixed with the app ID (`io.github.vietc.VietPlus.*`) or `flatpak build-export` will skip them
- **Daemon binary name**: Cargo builds the daemon binary as `vietc` (not `vietc-daemon`) in `target/release/`; rename on install to match the desktop file
- **Desktop Categories**: only use registered categories (`Utility`); `InputMethod` is not registered

View file

@ -6,78 +6,57 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
VERSION="${1:-0.1.4}"
echo "=== Building Viet+ Flatpak v${VERSION} ==="
# Install required runtimes
flatpak install -y flathub org.gnome.Platform//50 org.gnome.Sdk//50 2>/dev/null || true
flatpak install -y flathub org.freedesktop.Sdk.Extension.rust-stable//25.08 2>/dev/null || true
cd "$SCRIPT_DIR"
# Clean previous build
rm -rf build-dir vietc-repo VietPlus-*.flatpak
rm -rf build-dir repo VietPlus-*.flatpak
# Initialize build directory
# NOTE: arg order is flatpak build-init DIR APPNAME SDK RUNTIME
flatpak build-init build-dir io.github.vietc.VietPlus \
org.gnome.Platform//50 org.gnome.Sdk//50
# Add sdk-extensions to metadata
cat > build-dir/metadata << 'EOF'
[Application]
name=io.github.vietc.VietPlus
runtime=org.gnome.Platform/x86_64/50
sdk=org.gnome.Sdk/x86_64/50
sdk-extensions=org.freedesktop.Sdk.Extension.rust-stable
EOF
org.gnome.Sdk//50 org.gnome.Platform//50
# Copy source code
mkdir -p build-dir/files/src/vietc
rsync -a "$PROJECT_ROOT/" build-dir/files/src/vietc/ --exclude=target --exclude=.git
# Symlink Rust SDK extension
RUST_FILES=$(find /var/lib/flatpak/runtime/org.freedesktop.Sdk.Extension.rust-stable \
-name "rustc" -type f 2>/dev/null | head -1 | sed 's|/bin/rustc||')
mkdir -p build-dir/files/usr/lib/sdk
ln -s "$RUST_FILES" build-dir/files/usr/lib/sdk/rust-stable
BUILD='export PATH=/usr/lib/sdk/rust-stable/bin:$PATH
export CARGO_HOME=/app/cargo
cd /app/src/vietc'
# Build all Rust binaries inside sandbox
echo "Compiling daemon, CLI, uinputd..."
flatpak build --share=network build-dir sh -c '
export PATH=/usr/lib/sdk/rust-stable/bin:$PATH
export CARGO_HOME=/app/cargo
cd /app/src/vietc
cargo build --release -p vietc-daemon -p vietc-cli -p vietc-uinputd
'
# Build daemon + CLI + uinputd
echo ""
echo "=== Compiling daemon, CLI, uinputd... ==="
flatpak build --share=network build-dir sh -c "$BUILD && cargo build --release -p vietc-daemon -p vietc-cli -p vietc-uinputd"
echo "Compiling system tray..."
flatpak build --share=network build-dir sh -c '
export PATH=/usr/lib/sdk/rust-stable/bin:$PATH
export CARGO_HOME=/app/cargo
cd /app/src/vietc
cargo build --release --manifest-path ui/Cargo.toml
'
# Install files
echo ""
echo "=== Installing files... ==="
flatpak build build-dir sh -c "
set -e
install -Dm755 /app/src/vietc/target/release/vietc /app/bin/vietc-daemon
install -Dm755 /app/src/vietc/target/release/vietc-cli /app/bin/vietc-cli
install -Dm755 /app/src/vietc/target/release/vietc-uinputd /app/bin/vietc-uinputd
# Install files into sandbox
echo "Installing files..."
flatpak build build-dir sh -c '
set -e
install -Dm755 /app/src/vietc/target/release/vietc /app/bin/vietc
install -Dm755 /app/src/vietc/target/release/vietc-cli /app/bin/vietc-cli
install -Dm755 /app/src/vietc/target/release/vietc-uinputd /app/bin/vietc-uinputd
install -Dm755 /app/src/vietc/ui/target/release/vietc-tray /app/bin/vietc-tray
gcc -O2 -o /app/bin/vietc-xrecord /app/src/vietc/packaging/appimage/vietc-xrecord.c -lX11 -lXtst
install -Dm755 /app/src/vietc/packaging/flatpak/vietc-wrapper.sh /app/bin/vietc-wrapper.sh
install -Dm644 /app/src/vietc/packaging/appimage/vietc.desktop \
/app/share/applications/io.github.vietc.VietPlus.desktop
sed -i "s/Icon=vietc/Icon=io.github.vietc.VietPlus/g" \
/app/share/applications/io.github.vietc.VietPlus.desktop
install -Dm644 /app/src/vietc/vietc.toml /app/etc/vietc/config.toml
mkdir -p /app/share/icons/hicolor/256x256/apps
cp /app/src/vietc/packaging/appimage/AppDir/vietc.svg \
/app/share/icons/hicolor/256x256/apps/io.github.vietc.VietPlus.svg 2>/dev/null || true
mkdir -p /app/share/metainfo
cat > /app/share/metainfo/io.github.vietc.VietPlus.metainfo.xml << "XML"
<?xml version="1.0" encoding="utf-8"?>
<component type="desktop-application">
install -Dm644 /app/src/vietc/packaging/icons/vietc.svg /app/share/icons/hicolor/scalable/apps/io.github.vietc.VietPlus.svg
install -Dm644 /app/src/vietc/packaging/icons/vietc-vn.svg /app/share/icons/hicolor/scalable/apps/io.github.vietc.VietPlus.vietc-vn.svg
install -Dm644 /app/src/vietc/packaging/icons/vietc-en.svg /app/share/icons/hicolor/scalable/apps/io.github.vietc.VietPlus.vietc-en.svg
cat > /app/share/applications/io.github.vietc.VietPlus.desktop << END
[Desktop Entry]
Name=Viet+
Comment=Vietnamese Input Method
Exec=/app/bin/vietc-daemon
Icon=io.github.vietc.VietPlus
Terminal=false
Type=Application
Categories=Utility;
END
mkdir -p /app/share/metainfo
cat > /app/share/metainfo/io.github.vietc.VietPlus.metainfo.xml << 'XML'
<?xml version='1.0' encoding='utf-8'?>
<component type='desktop-application'>
<id>io.github.vietc.VietPlus</id>
<name>Viet+</name>
<summary>Vietnamese Input Method for Linux</summary>
@ -86,36 +65,39 @@ flatpak build build-dir sh -c '
</description>
<metadata_license>MIT</metadata_license>
<project_license>MIT</project_license>
<url type="homepage">https://github.com/vndangkhoa/vietc</url>
<provides><binary>vietc</binary></provides>
<url type='homepage'>https://github.com/vndangkhoa/vietc</url>
<provides><binary>vietc-daemon</binary></provides>
<categories><category>Utility</category></categories>
</component>
XML
mkdir -p /app/share/doc/vietc
cp /app/src/vietc/README.md /app/share/doc/vietc/ 2>/dev/null || true
cp /app/src/vietc/LICENSE /app/share/doc/vietc/ 2>/dev/null || true
'
"
# Finish the build
echo "Finalizing build..."
# Finish
echo ""
echo "=== Finalizing build... ==="
flatpak build-finish build-dir \
--socket=x11 \
--socket=wayland \
--socket=session-bus \
--filesystem=home \
--share=ipc \
--device=all \
--command=vietc-wrapper.sh
--talk-name=org.freedesktop.Notifications \
--talk-name=org.a11y.Bus \
--command=vietc-daemon
# Export to local repository
echo "Exporting to repository..."
flatpak build-export vietc-repo build-dir
# Export
echo ""
echo "=== Exporting to repository... ==="
flatpak build-export repo build-dir
# Create single-file bundle
echo "Creating bundle..."
flatpak build-bundle vietc-repo "VietPlus-${VERSION}.flatpak" io.github.vietc.VietPlus
# Bundle
echo ""
echo "=== Creating bundle... ==="
flatpak build-bundle repo "VietPlus-${VERSION}.flatpak" io.github.vietc.VietPlus
echo ""
echo "=== Done ==="
echo "Package: $SCRIPT_DIR/VietPlus-${VERSION}.flatpak ($(du -h "$SCRIPT_DIR/VietPlus-${VERSION}.flatpak" | cut -f1))"
echo "Package: $SCRIPT_DIR/VietPlus-${VERSION}.flatpak"
echo "Size: $(du -h "$SCRIPT_DIR/VietPlus-${VERSION}.flatpak" | cut -f1)"
echo ""
echo "Install: flatpak install --user --bundle VietPlus-${VERSION}.flatpak"
echo "Run: flatpak run io.github.vietc.VietPlus"
echo "Run: flatpak run io.github.vietc.VietPlus"

View file

@ -1,40 +1,21 @@
{
"app-id": "io.github.vietc.VietPlus",
"runtime": "org.gnome.Platform",
"runtime-version": "47",
"runtime-version": "50",
"sdk": "org.gnome.Sdk",
"sdk-extensions": [
"org.freedesktop.Sdk.Extension.rust-stable"
],
"command": "vietc-wrapper.sh",
"rename-desktop-file": "vietc.desktop",
"rename-icon": "vietc",
"command": "vietc-daemon",
"finish-args": [
"--socket=x11",
"--socket=wayland",
"--socket=session-bus",
"--device=all",
"--share=ipc"
"--filesystem=home",
"--share=ipc",
"--talk-name=org.freedesktop.Notifications",
"--talk-name=org.a11y.Bus"
],
"modules": [
{
"name": "xclip",
"no-autogen": true,
"make-install-args": [
"install"
],
"builddir": true,
"sources": [
{
"type": "archive",
"url": "https://github.com/astrand/xclip/archive/refs/tags/0.13.tar.gz",
"sha256": "ca5b8804e3c910a66423a882d79bf3c9450e8758faa5d2b9deba5342451b140e"
}
],
"cleanup": [
"/share/man"
]
},
{
"name": "vietc",
"buildsystem": "simple",
@ -47,57 +28,25 @@
"build-commands": [
"cargo build --release -p vietc-daemon -p vietc-cli -p vietc-uinputd --manifest-path /run/build/vietc/Cargo.toml",
"cd /run/build/vietc/ui && cargo build --release --manifest-path /run/build/vietc/ui/Cargo.toml || echo 'tray build skipped'",
"install -Dm755 target/release/vietc /app/bin/vietc",
"install -Dm755 target/release/vietc /app/bin/vietc-daemon",
"install -Dm755 target/release/vietc-cli /app/bin/vietc-cli",
"install -Dm755 target/release/vietc-uinputd /app/bin/vietc-uinputd",
"[ -f ui/target/release/vietc-tray ] && install -Dm755 ui/target/release/vietc-tray /app/bin/vietc-tray || true",
"gcc -O2 -o /app/bin/vietc-xrecord /run/build/vietc/packaging/appimage/vietc-xrecord.c -lX11 -lXtst",
"install -Dm644 packaging/icons/vietc.svg /app/share/icons/hicolor/scalable/apps/io.github.vietc.VietPlus.svg",
"install -Dm644 packaging/icons/vietc-vn.svg /app/share/icons/hicolor/scalable/apps/io.github.vietc.VietPlus.vietc-vn.svg",
"install -Dm644 packaging/icons/vietc-en.svg /app/share/icons/hicolor/scalable/apps/io.github.vietc.VietPlus.vietc-en.svg",
"install -Dm755 /run/build/vietc/packaging/flatpak/vietc-wrapper.sh /app/bin/vietc-wrapper.sh",
"install -Dm644 /run/build/vietc/packaging/appimage/vietc.desktop /app/share/applications/vietc.desktop",
"install -Dm644 /run/build/vietc/vietc.toml /app/etc/vietc/config.toml",
"mkdir -p /app/share/icons/hicolor/256x256/apps",
"cp /run/build/vietc/packaging/appimage/AppDir/vietc.svg /app/share/icons/hicolor/256x256/apps/vietc.svg || true",
"install -Dm644 /run/build/vietc/vietc.service /app/lib/systemd/user/vietc.service || true",
"mkdir -p /app/share/applications",
"cat > /app/share/applications/io.github.vietc.VietPlus.desktop << END\n[Desktop Entry]\nName=Viet+\nComment=Vietnamese Input Method\nExec=/app/bin/vietc-daemon\nIcon=io.github.vietc.VietPlus\nTerminal=false\nType=Application\nCategories=Utility;\nEND",
"mkdir -p /app/share/metainfo",
"cat > /app/share/metainfo/io.github.vietc.VietPlus.metainfo.xml << 'XML'",
"<?xml version='1.0' encoding='utf-8'?>",
"<component type='desktop-application'>",
" <id>io.github.vietc.VietPlus</id>",
" <name>Viet+</name>",
" <summary>Vietnamese Input Method for Linux</summary>",
" <description>",
" <p>Zero-configuration Vietnamese input method engine supporting Telex and VNI input methods.</p>",
" </description>",
" <metadata_license>MIT</metadata_license>",
" <project_license>MIT</project_license>",
" <url type='homepage'>https://github.com/vndangkhoa/vietc</url>",
" <provides><binary>vietc</binary></provides>",
" <categories><category>Utility</category></categories>",
"</component>",
"XML",
"mkdir -p /app/share/doc/vietc",
"cp /run/build/vietc/README.md /app/share/doc/vietc/ || true",
"cp /run/build/vietc/LICENSE /app/share/doc/vietc/ || true"
"cat > /app/share/metainfo/io.github.vietc.VietPlus.metainfo.xml << 'XML'\n<?xml version='1.0' encoding='utf-8'?>\n<component type='desktop-application'>\n <id>io.github.vietc.VietPlus</id>\n <name>Viet+</name>\n <summary>Vietnamese Input Method for Linux</summary>\n <description>\n <p>Zero-configuration Vietnamese input method engine supporting Telex and VNI input methods.</p>\n </description>\n <metadata_license>MIT</metadata_license>\n <project_license>MIT</project_license>\n <url type='homepage'>https://github.com/vndangkhoa/vietc</url>\n <provides><binary>vietc-daemon</binary></provides>\n <categories><category>Utility</category></categories>\n</component>\nXML"
],
"sources": [
{
"type": "dir",
"path": "../.."
}
],
"cleanup": [
"/app/share/doc",
"/app/share/icons"
]
}
]

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<rect x="8" y="8" width="112" height="112" rx="24" fill="#4b5563"/>
<text x="64" y="96" text-anchor="middle" fill="#ffffff" font-size="48" font-weight="bold" font-family="system-ui, sans-serif">EN</text>
</svg>

After

Width:  |  Height:  |  Size: 278 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<rect x="8" y="8" width="112" height="112" rx="24" fill="#e02424"/>
<text x="64" y="96" text-anchor="middle" fill="#ffffff" font-size="48" font-weight="bold" font-family="system-ui, sans-serif">VN</text>
</svg>

After

Width:  |  Height:  |  Size: 278 B

32
packaging/icons/vietc.svg Normal file
View file

@ -0,0 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
<defs>
<linearGradient id="kb-bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3a3a3a"/>
<stop offset="100%" stop-color="#1a1a1a"/>
</linearGradient>
<linearGradient id="vn-badge" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#e74c3c"/>
<stop offset="100%" stop-color="#c0392b"/>
</linearGradient>
</defs>
<rect x="16" y="56" width="224" height="156" rx="18" fill="url(#kb-bg)" stroke="#555" stroke-width="3"/>
<rect x="32" y="72" width="192" height="124" rx="10" fill="#2a2a2a"/>
<rect x="44" y="84" width="26" height="22" rx="4" fill="#e0e0e0"/>
<rect x="76" y="84" width="26" height="22" rx="4" fill="#e0e0e0"/>
<rect x="108" y="84" width="26" height="22" rx="4" fill="#e0e0e0"/>
<rect x="140" y="84" width="26" height="22" rx="4" fill="#e0e0e0"/>
<rect x="172" y="84" width="26" height="22" rx="4" fill="#e0e0e0"/>
<rect x="50" y="112" width="26" height="22" rx="4" fill="#e0e0e0"/>
<rect x="82" y="112" width="26" height="22" rx="4" fill="#e0e0e0"/>
<rect x="114" y="112" width="26" height="22" rx="4" fill="#e0e0e0"/>
<rect x="146" y="112" width="26" height="22" rx="4" fill="#e0e0e0"/>
<rect x="178" y="112" width="26" height="22" rx="4" fill="#e0e0e0"/>
<rect x="56" y="140" width="26" height="22" rx="4" fill="#e0e0e0"/>
<rect x="88" y="140" width="26" height="22" rx="4" fill="#e0e0e0"/>
<rect x="120" y="140" width="26" height="22" rx="4" fill="#e0e0e0"/>
<rect x="152" y="140" width="26" height="22" rx="4" fill="#e0e0e0"/>
<rect x="184" y="140" width="30" height="22" rx="4" fill="#e0e0e0"/>
<rect x="68" y="168" width="120" height="16" rx="4" fill="#e0e0e0"/>
<circle cx="224" cy="44" r="30" fill="url(#vn-badge)"/>
<text x="224" y="52" text-anchor="middle" fill="#fff" font-size="20" font-weight="bold" font-family="sans-serif">VN</text>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]

View file

@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
pub mod inject;
pub mod monitor;
pub mod uinput_monitor;

View file

@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
use crate::inject::KeyEvent;
pub trait KeyMonitor {

View file

@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::PathBuf;

View file

@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
use std::fs::{File, OpenOptions};
use std::os::unix::io::AsRawFd;
use std::sync::{Arc, Condvar, Mutex};

View file

@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
use std::collections::HashMap;
use crate::inject::{InjectResult, KeyInjector};

View file

@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
use std::collections::VecDeque;
use std::ffi::{c_char, c_int, c_void};
use std::io::{Read, BufRead};

View file

@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
use super::inject::{InjectResult, KeyInjector};
use std::cell::RefCell;
use std::ffi::{c_char, c_int, c_void};

View file

@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;

View file

@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
use std::path::PathBuf;
mod config;

View file

@ -1,6 +1,11 @@
// SPDX-License-Identifier: MIT
use crate::config;
use ksni::{menu::*, MenuItem, Tray};
fn is_flatpak() -> bool {
std::path::Path::new("/app/bin/vietc-daemon").exists()
}
fn write_status(state: &str) {
if let Some(config_dir) = dirs::config_dir() {
let _ = std::fs::write(config_dir.join("vietc").join("status"), state);
@ -242,7 +247,13 @@ impl Tray for VietTray {
}
fn icon_name(&self) -> String {
if self.mode == "vn" {
if is_flatpak() {
if self.mode == "vn" {
"io.github.vietc.VietPlus.vietc-vn".into()
} else {
"io.github.vietc.VietPlus.vietc-en".into()
}
} else if self.mode == "vn" {
"vietc-vn".into()
} else {
"vietc-en".into()
@ -250,9 +261,20 @@ impl Tray for VietTray {
}
fn icon_theme_path(&self) -> String {
// Use XDG user theme path for icons
dirs::home_dir()
.map(|d| d.join(".local/share/icons").to_string_lossy().into_owned())
// Use XDG user theme path for icons (works in both native and Flatpak)
if let Some(home) = dirs::home_dir() {
let user_path = home.join(".local/share/icons");
if user_path.exists() {
return user_path.to_string_lossy().into_owned();
}
}
// Flatpak: icons are in /app/share/icons
let flatpak_path = std::path::Path::new("/app/share/icons");
if flatpak_path.exists() {
return "/app/share/icons".into();
}
dirs::data_dir()
.map(|d| d.join("icons").to_string_lossy().into_owned())
.unwrap_or_else(|| "/usr/share/icons".into())
}

View file

@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
use std::fs;
use std::os::unix::io::AsRawFd;
use std::os::unix::net::{UnixListener, UnixStream};