feat(appd): add QueryInstalledApps IPC request; wire launcher in system UI

This commit is contained in:
Marco Allegretti 2026-03-11 11:23:46 +01:00
parent d6de84b4c7
commit e1c15ea463
4 changed files with 173 additions and 3 deletions

View file

@ -11,6 +11,7 @@ path = "src/main.rs"
[dependencies] [dependencies]
anyhow = "1.0" anyhow = "1.0"
sd-notify = "0.4" sd-notify = "0.4"
toml = "0.8"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "signal", "sync", "process", "time"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "signal", "sync", "process", "time"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
rmp-serde = "1" rmp-serde = "1"

View file

@ -8,6 +8,7 @@ pub enum Request {
TerminateApp { session_id: u64 }, TerminateApp { session_id: u64 },
QueryRunning, QueryRunning,
QueryAppState { session_id: u64 }, QueryAppState { session_id: u64 },
QueryInstalledApps,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -16,6 +17,12 @@ pub struct SessionInfo {
pub app_id: String, pub app_id: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppInfo {
pub app_id: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "SCREAMING_SNAKE_CASE")] #[serde(tag = "type", rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Response { pub enum Response {
@ -34,6 +41,9 @@ pub enum Response {
session_id: u64, session_id: u64,
state: AppStateKind, state: AppStateKind,
}, },
InstalledApps {
apps: Vec<AppInfo>,
},
Error { Error {
code: u32, code: u32,
message: String, message: String,

View file

@ -9,7 +9,7 @@ mod ipc;
mod runtime; mod runtime;
mod ws; mod ws;
use ipc::{AppStateKind, Request, Response, SessionInfo}; use ipc::{AppInfo, AppStateKind, Request, Response, SessionInfo};
pub(crate) type Registry = Arc<Mutex<SessionRegistry>>; pub(crate) type Registry = Arc<Mutex<SessionRegistry>>;
@ -254,7 +254,66 @@ pub(crate) async fn dispatch(req: Request, registry: &Registry) -> Response {
let state = registry.lock().await.state(session_id); let state = registry.lock().await.state(session_id);
Response::AppState { session_id, state } Response::AppState { session_id, state }
} }
Request::QueryInstalledApps => {
let apps = scan_installed_apps();
Response::InstalledApps { apps }
} }
}
}
fn app_store_roots() -> Vec<std::path::PathBuf> {
if let Ok(explicit) = std::env::var("WEFT_APP_STORE") {
return vec![std::path::PathBuf::from(explicit)];
}
let mut roots = Vec::new();
if let Ok(home) = std::env::var("HOME") {
roots.push(
std::path::PathBuf::from(home)
.join(".local")
.join("share")
.join("weft")
.join("apps"),
);
}
roots.push(std::path::PathBuf::from("/usr/share/weft/apps"));
roots
}
#[derive(serde::Deserialize)]
struct WappPackage {
id: String,
name: String,
}
#[derive(serde::Deserialize)]
struct WappManifest {
package: WappPackage,
}
fn scan_installed_apps() -> Vec<AppInfo> {
let mut seen = std::collections::HashSet::new();
let mut apps = Vec::new();
for root in app_store_roots() {
let Ok(entries) = std::fs::read_dir(&root) else {
continue;
};
for entry in entries.flatten() {
let manifest_path = entry.path().join("wapp.toml");
let Ok(contents) = std::fs::read_to_string(&manifest_path) else {
continue;
};
let Ok(m) = toml::from_str::<WappManifest>(&contents) else {
continue;
};
if seen.insert(m.package.id.clone()) {
apps.push(AppInfo {
app_id: m.package.id,
name: m.package.name,
});
}
}
}
apps
} }
fn appd_socket_path() -> anyhow::Result<PathBuf> { fn appd_socket_path() -> anyhow::Result<PathBuf> {
@ -432,6 +491,36 @@ mod tests {
assert!(matches!(reg.state(42), AppStateKind::NotFound)); assert!(matches!(reg.state(42), AppStateKind::NotFound));
} }
#[test]
fn scan_installed_apps_finds_valid_packages() {
use std::fs;
let store = std::env::temp_dir().join(format!("weft_appd_scan_{}", std::process::id()));
let app_dir = store.join("com.example.scanner");
fs::create_dir_all(&app_dir).unwrap();
fs::write(
app_dir.join("wapp.toml"),
"[package]\nid = \"com.example.scanner\"\nname = \"Scanner\"\nversion = \"1.0.0\"\n\
[runtime]\nmodule = \"app.wasm\"\n[ui]\nentry = \"ui/index.html\"\n",
)
.unwrap();
let prior = std::env::var("WEFT_APP_STORE").ok();
unsafe { std::env::set_var("WEFT_APP_STORE", &store) };
let apps = scan_installed_apps();
assert_eq!(apps.len(), 1);
assert_eq!(apps[0].app_id, "com.example.scanner");
assert_eq!(apps[0].name, "Scanner");
unsafe {
match prior {
Some(v) => std::env::set_var("WEFT_APP_STORE", v),
None => std::env::remove_var("WEFT_APP_STORE"),
}
}
let _ = fs::remove_dir_all(&store);
}
#[cfg(unix)] #[cfg(unix)]
#[tokio::test(flavor = "current_thread")] #[tokio::test(flavor = "current_thread")]
async fn supervisor_transitions_through_ready_to_stopped() { async fn supervisor_transitions_through_ready_to_stopped() {

View file

@ -94,12 +94,45 @@
backdrop-filter: blur(32px); backdrop-filter: blur(32px);
-webkit-backdrop-filter: blur(32px); -webkit-backdrop-filter: blur(32px);
z-index: 100; z-index: 100;
padding: 24px;
overflow-y: auto;
} }
weft-launcher:not([hidden]) { weft-launcher:not([hidden]) {
display: block; display: block;
} }
weft-app-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: 16px;
max-width: 640px;
margin: 0 auto;
}
weft-app-icon {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px 8px;
border-radius: 12px;
cursor: pointer;
color: var(--text-primary);
font-size: 12px;
text-align: center;
word-break: break-word;
transition: background 0.15s;
}
weft-app-icon:hover {
background: var(--surface-border);
}
weft-app-icon svg {
flex-shrink: 0;
}
weft-notification-center { weft-notification-center {
position: fixed; position: fixed;
top: 8px; top: 8px;
@ -138,7 +171,9 @@
</weft-taskbar> </weft-taskbar>
</weft-desktop> </weft-desktop>
<weft-launcher hidden id="launcher"></weft-launcher> <weft-launcher hidden id="launcher">
<weft-app-grid id="app-grid"></weft-app-grid>
</weft-launcher>
<weft-notification-center id="notifications"></weft-notification-center> <weft-notification-center id="notifications"></weft-notification-center>
<script> <script>
@ -177,6 +212,7 @@
ws.addEventListener('open', function () { ws.addEventListener('open', function () {
wsReconnectDelay = 1000; wsReconnectDelay = 1000;
ws.send(JSON.stringify({ type: 'QUERY_RUNNING' })); ws.send(JSON.stringify({ type: 'QUERY_RUNNING' }));
ws.send(JSON.stringify({ type: 'QUERY_INSTALLED_APPS' }));
}); });
ws.addEventListener('message', function (ev) { ws.addEventListener('message', function (ev) {
@ -201,7 +237,9 @@
} }
function handleAppdMessage(msg) { function handleAppdMessage(msg) {
if (msg.type === 'APP_READY') { if (msg.type === 'INSTALLED_APPS') {
renderLauncher(msg.apps || []);
} else if (msg.type === 'APP_READY') {
var label = msg.app_id ? msg.app_id.split('.').pop() : String(msg.session_id); var label = msg.app_id ? msg.app_id.split('.').pop() : String(msg.session_id);
showNotification(label + ' is ready'); showNotification(label + ' is ready');
} else if (msg.type === 'APP_STATE') { } else if (msg.type === 'APP_STATE') {
@ -217,6 +255,38 @@
} }
} }
function renderLauncher(apps) {
var grid = document.getElementById('app-grid');
grid.innerHTML = '';
if (apps.length === 0) {
var empty = document.createElement('p');
empty.textContent = 'No apps installed';
empty.style.cssText = 'color:var(--text-secondary);font-size:13px;';
grid.appendChild(empty);
return;
}
apps.forEach(function (app) {
var icon = document.createElement('weft-app-icon');
icon.dataset.appId = app.app_id;
icon.title = app.app_id;
icon.innerHTML =
'<svg width="40" height="40" viewBox="0 0 40 40" fill="none"' +
' xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
'<rect width="40" height="40" rx="10" fill="rgba(91,138,245,0.25)"/>' +
'<text x="20" y="27" text-anchor="middle" font-size="18" fill="rgba(255,255,255,0.8)">' +
(app.name ? app.name.charAt(0).toUpperCase() : '?') +
'</text></svg>' +
'<span>' + (app.name || app.app_id) + '</span>';
icon.addEventListener('click', function () {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'LAUNCH_APP', app_id: app.app_id, surface_id: 0 }));
}
document.getElementById('launcher').setAttribute('hidden', '');
});
grid.appendChild(icon);
});
}
function ensureTaskbarEntry(sessionId, appId) { function ensureTaskbarEntry(sessionId, appId) {
var id = 'task-' + sessionId; var id = 'task-' + sessionId;
if (document.getElementById(id)) { return; } if (document.getElementById(id)) { return; }