Skip to content

Commit 5b3f23f

Browse files
olafkfreundclaude
andcommitted
fix: eliminate panics, harden security, and improve UX
- Remove all .unwrap()/.expect() from production code paths, replacing with proper error handling via Result types and graceful fallbacks - Fix desktop entry injection by sanitizing Name/Exec/WMClass fields - Fix path traversal via app_id by stripping /, \, and .. sequences - Add with_navigation_handler to webview blocking non-http/https URLs - Add with_new_window_req_handler to block unsafe new window requests - Validate URL scheme (http/https only) before loading in webview - Harden icon-installer.sh: fix TOCTOU race, quote variables, add trap - Add inline validation for title/URL fields in editor - Add numeric-only filter with clamping for window size input - Add toast notifications for save/delete via widget::toaster - Add Ctrl+N keyboard shortcut for creating new web app - Restructure app menu with Create new + divider + Settings + About - Add RON file size limit (64KB), WalkDir depth limit (8), icon cap (200) - Bound downloader output buffer to 32KB - Make SVG extension check case-insensitive - Add empty icon search state message - Add CLAUDE.md with project documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3011ef1 commit 5b3f23f

11 files changed

Lines changed: 437 additions & 145 deletions

File tree

CLAUDE.md

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
**Quick Web Apps** (`dev.heppen.webapps`) is a web app manager for the COSMIC desktop environment. Users create, manage, and launch web applications that run in isolated WebKitGTK webview windows. Built with Rust using `libcosmic` (iced-based GUI framework) and `wry`/`tao` for the webview runtime.
8+
9+
## Build Commands
10+
11+
The project uses `just` as the task runner:
12+
13+
```bash
14+
just # Build release (default)
15+
just build-debug # Debug build
16+
just build-release # Release build
17+
just check # Clippy with -W clippy::pedantic
18+
just run # Run with RUST_BACKTRACE=full
19+
just dev # cargo fmt + run
20+
just clean # cargo clean
21+
sudo just install # Install binaries, desktop entry, icons to /app/
22+
```
23+
24+
Flatpak build (primary distribution method):
25+
```bash
26+
flatpak run --command=flathub-build org.flatpak.Builder --install dev.heppen.webapps.json
27+
```
28+
29+
There is no test suite. There is no flake.nix — use the Containerfile in `.devcontainer/` or system-installed deps.
30+
31+
### System Dependencies
32+
33+
Building requires: `pkg-config`, `libssl-dev`, `libxkbcommon-dev`, `libwebkit2gtk-4.1-dev`, `just`
34+
35+
## Architecture
36+
37+
### Two Binaries
38+
39+
The crate produces two binaries (see `Cargo.toml [[bin]]` sections):
40+
41+
1. **`dev-heppen-webapps`** (`src/bin/dev-heppen-webapps/main.rs`) — The main GUI application where users create/edit/delete web apps
42+
2. **`dev-heppen-webapps-webview`** (`src/bin/webview.rs`) — Lightweight webview process spawned per web app. Reads config from the database, creates a GTK+WebKitGTK window, and runs an event loop
43+
44+
Each web app runs as a separate `dev-heppen-webapps-webview <app_id>` process.
45+
46+
### Library Layer (`src/`)
47+
48+
- **`lib.rs`** — Core types (`Icon`, `IconType`, `Category`, `WindowSize`, `WebviewArgs`), XDG path helpers (`database_path()`, `profiles_path()`, `icons_location()`), icon search/validation, URL validation
49+
- **`browser.rs`**`Browser` struct: app configuration (URL, title, profile path, window size, decorations, private mode, mobile simulation). Serialized to/from RON
50+
- **`launcher.rs`**`WebAppLauncher` struct: wraps `Browser` + name/icon/category. Uses `ashpd` (XDG Desktop Portal) `DynamicLauncher` to create/delete `.desktop` entries. Stores webapp data as `.ron` files in the database directory
51+
- **`localize.rs`** — i18n via `i18n-embed` with Fluent. Uses `fl!()` macro. Translation files: `i18n/{lang}/webapps.ftl`
52+
53+
### GUI Application (`src/bin/dev-heppen-webapps/`)
54+
55+
- **`pages/mod.rs`**`QuickWebApps`: the `cosmic::Application` implementation. Manages nav bar (installed apps list), dialogs (icon picker, delete confirmation, icon downloader), theme system, and config subscription
56+
- **`pages/editor.rs`**`AppEditor`: form for creating/editing a web app (title, URL, icon, category, window size, toggles for persistent profile/decorations/private mode/mobile simulation)
57+
- **`pages/iconpicker.rs`**`IconPicker`: modal dialog for searching system icon packs (Papirus) or picking custom files
58+
- **`config.rs`**`AppConfig` with CosmicConfig integration (persists theme choice)
59+
- **`themes.rs`** — Light/Dark built-in themes + custom RON theme import
60+
61+
### Key Data Flow
62+
63+
**Creating a web app:**
64+
1. User fills `AppEditor` form → generates unique `app_id` (title + random 4-digit suffix)
65+
2. `WebAppLauncher::create()` calls XDG DynamicLauncher portal to install a `.desktop` entry
66+
3. Launcher config saved as RON to `$XDG_DATA_HOME/dev.heppen.webapps/database/{app_id}.ron`
67+
68+
**Launching a web app:**
69+
1. Desktop entry runs `dev.heppen.webapps.webview {app_id}`
70+
2. Webview binary loads `Browser::from_appid()` from the RON database
71+
3. Creates GTK window with WebKitGTK webview using stored settings
72+
73+
### Data Storage (all XDG-compliant)
74+
75+
| Path | Content |
76+
|------|---------|
77+
| `$XDG_DATA_HOME/dev.heppen.webapps/database/*.ron` | Webapp configs (RON format) |
78+
| `$XDG_DATA_HOME/dev.heppen.webapps/profiles/{app_id}/` | Per-app WebKitGTK browser data |
79+
| `$XDG_DATA_HOME/dev.heppen.webapps/icons/` | Cached icons |
80+
| `$XDG_DATA_HOME/dev.heppen.webapps/themes/` | Custom theme RON files |
81+
| `$XDG_CONFIG_HOME/cosmic/{version}/dev.heppen.webapps.ron` | App config (via CosmicConfig) |
82+
83+
## i18n
84+
85+
14 languages supported. English source: `i18n/en/webapps.ftl`. Add translations by creating `i18n/{lang_code}/webapps.ftl`. Strings are accessed via `fl!("key")` or `fl!("key", arg = value)`.
86+
87+
## App ID
88+
89+
The app ID `dev.heppen.webapps` is used throughout: Flatpak manifest, desktop entry, config paths, binary naming. It is defined as `APPID` in the justfile and `APP_ID` constant in `src/lib.rs`.
90+
91+
## Key Dependencies
92+
93+
- **libcosmic** (git dep from pop-os/libcosmic) — COSMIC app framework, provides `cosmic::Application` trait, widgets, nav bar, config system, theme engine
94+
- **wry** + **tao** + **gtk** — WebKitGTK webview creation and window management (webview binary only)
95+
- **ashpd** — XDG Desktop Portal client for DynamicLauncher (creating/removing `.desktop` entries)
96+
- **ron** — Rusty Object Notation for config serialization
97+
- **i18n-embed** + **i18n-embed-fl** — Compile-time embedded Fluent translations

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

i18n/en/webapps.ftl

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ icon-name-to-find=Icon name to find
7474
my-icons=My icons
7575
download=Download
7676
search=Search
77+
no-icons-found=No icons found. Try a different search term or upload a custom icon.
7778
7879
# icons_installator.rs
7980
icons-installer-header=Please wait. Downloading icons...
@@ -89,3 +90,7 @@ warning=You don't meet requirements
8990
.app-url= - You must provide valid URL starting with http:// or https://
9091
.app-icon= - You must select an Icon for your launcher
9192
.app-browser= - Please select a browser. Make sure at least one is installed system-wide or via Flatpak
93+
94+
# toast notifications
95+
toast-app-saved=Web app saved successfully
96+
toast-app-deleted=Web app deleted

resources/scripts/icon-installer.sh

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,21 @@ gh_repo="papirus-icon-theme"
88
gh_desc="Papirus icon theme"
99

1010
: "${XDG_DATA_HOME:=$HOME/.local/share}"
11-
: "${EXTRA_THEMES=Papirus Papirus-Dark Papirus-Light}"
1211
: "${TAG:=master}"
1312

14-
temp_file="$(mktemp -u)"
13+
EXTRA_THEMES="Papirus Papirus-Dark Papirus-Light"
14+
15+
temp_file="$(mktemp)"
1516
temp_dir="$(mktemp -d)"
1617

18+
cleanup() {
19+
echo "Clearing cache ..."
20+
rm -rf "$temp_file" "$temp_dir"
21+
echo "Done!"
22+
}
23+
24+
trap cleanup EXIT HUP INT TERM
25+
1726
download() {
1827
echo "Getting the latest version from GitHub ..."
1928
wget -O "$temp_file" \
@@ -22,31 +31,21 @@ download() {
2231
tar -xzf "$temp_file" -C "$temp_dir"
2332
}
2433

25-
2634
install() {
27-
# shellcheck disable=2068
28-
set -- $@ # split args by space
35+
dest="$1"
36+
shift
2937

3038
for theme in "$@"; do
3139
test -d "$temp_dir/$gh_repo-$TAG/$theme" || continue
3240
echo "Installing '$theme' ..."
33-
cp -R "$temp_dir/$gh_repo-$TAG/$theme" $1
41+
cp -R "$temp_dir/$gh_repo-$TAG/$theme" "$dest"
3442
done
3543
}
3644

37-
cleanup() {
38-
echo "Clearing cache ..."
39-
rm -rf "$temp_file" "$temp_dir"
40-
echo "Done!"
41-
}
42-
43-
4445
download
4546

4647
install_path="$XDG_DATA_HOME/$APP_ID/icons"
4748

48-
error_message=$(mkdir -p $install_path 2>&1)
49+
mkdir -p "$install_path"
4950

50-
install $install_path $EXTRA_THEMES
51-
52-
trap cleanup EXIT HUP INT TERM
51+
install "$install_path" $EXTRA_THEMES

src/bin/dev-heppen-webapps/pages/editor.rs

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -188,12 +188,17 @@ impl AppEditor {
188188
self.app_window_decorations = decorations;
189189
}
190190
Message::WindowWidth(width) => {
191-
self.app_window_width = width;
192-
self.app_window_size.0 = self.app_window_width.parse().unwrap_or_default();
191+
// Only accept numeric input
192+
let filtered: String = width.chars().filter(|c| c.is_ascii_digit() || *c == '.').collect();
193+
self.app_window_width = filtered;
194+
let parsed: f64 = self.app_window_width.parse().unwrap_or(webapps::DEFAULT_WINDOW_WIDTH);
195+
self.app_window_size.0 = parsed.clamp(200.0, 8192.0);
193196
}
194197
Message::WindowHeight(height) => {
195-
self.app_window_height = height;
196-
self.app_window_size.1 = self.app_window_height.parse().unwrap_or_default();
198+
let filtered: String = height.chars().filter(|c| c.is_ascii_digit() || *c == '.').collect();
199+
self.app_window_height = filtered;
200+
let parsed: f64 = self.app_window_height.parse().unwrap_or(webapps::DEFAULT_WINDOW_HEIGHT);
201+
self.app_window_size.1 = parsed.clamp(200.0, 8192.0);
197202
}
198203
}
199204
Task::none()
@@ -274,7 +279,23 @@ impl AppEditor {
274279
.class(style::Container::Card),
275280
)
276281
.push(widget::text_input(fl!("title"), &self.app_title).on_input(Message::Title))
282+
.push_maybe(
283+
if !self.app_title.is_empty() && self.app_title.len() < 3 {
284+
Some(widget::text::caption(fl!("warning.app-name"))
285+
.class(style::Text::Accent))
286+
} else {
287+
None
288+
}
289+
)
277290
.push(widget::text_input(fl!("url"), &self.app_url).on_input(Message::Url))
291+
.push_maybe(
292+
if !self.app_url.is_empty() && !webapps::url_valid(&self.app_url) {
293+
Some(widget::text::caption(fl!("warning.app-url"))
294+
.class(style::Text::Accent))
295+
} else {
296+
None
297+
}
298+
)
278299
.push(
279300
widget::settings::section()
280301
.add(widget::settings::item(

src/bin/dev-heppen-webapps/pages/iconpicker.rs

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ pub enum Message {
2323
pub struct IconPicker {
2424
pub icon_searching: String,
2525
pub icons: Vec<webapps::Icon>,
26+
pub has_searched: bool,
2627
}
2728

2829
impl IconPicker {
@@ -36,7 +37,7 @@ impl IconPicker {
3637
Message::DownloadIconsPack => return task::message(pages::Message::DownloaderStarted),
3738
Message::OpenIconPickerDialog => {
3839
return task::future(async move {
39-
let result = SelectedFiles::open_file()
40+
let response = match SelectedFiles::open_file()
4041
.title("Open multiple images")
4142
.accept_label("Open")
4243
.modal(true)
@@ -45,10 +46,15 @@ impl IconPicker {
4546
.filter(FileFilter::new("SVG Images").glob("*.svg"))
4647
.send()
4748
.await
48-
.unwrap()
49-
.response();
49+
{
50+
Ok(r) => r.response(),
51+
Err(e) => {
52+
tracing::error!("Failed to open file chooser: {e}");
53+
return pages::Message::None;
54+
}
55+
};
5056

51-
if let Ok(result) = result {
57+
if let Ok(result) = response {
5258
let files = result
5359
.uris()
5460
.iter()
@@ -63,6 +69,7 @@ impl IconPicker {
6369
}
6470
Message::IconSearch => {
6571
self.icons.clear();
72+
self.has_searched = true;
6673

6774
let name = self.icon_searching.clone().to_lowercase();
6875

@@ -100,7 +107,7 @@ impl IconPicker {
100107
.on_submit(|_| Message::IconSearch);
101108
let button = widget::button::standard(fl!("open")).on_press(Message::OpenIconPickerDialog);
102109

103-
widget::column()
110+
let mut col = widget::column()
104111
.spacing(30)
105112
.push(
106113
widget::container(
@@ -118,15 +125,24 @@ impl IconPicker {
118125
}),
119126
)
120127
.padding(8),
121-
)
122-
.push_maybe(if !icons.is_empty() {
123-
Some(
124-
widget::container(widget::scrollable(widget::flex_row(icons)))
125-
.height(Length::FillPortion(1)),
128+
);
129+
130+
if !icons.is_empty() {
131+
col = col.push(
132+
widget::container(widget::scrollable(widget::flex_row(icons)))
133+
.height(Length::FillPortion(1)),
134+
);
135+
} else if self.has_searched {
136+
col = col.push(
137+
widget::container(
138+
widget::text::body(fl!("no-icons-found"))
126139
)
127-
} else {
128-
None
129-
})
130-
.into()
140+
.padding(20)
141+
.width(Length::Fill)
142+
.align_x(cosmic::iced::Alignment::Center),
143+
);
144+
}
145+
146+
col.into()
131147
}
132148
}

0 commit comments

Comments
 (0)