Skip to content

Commit e3b88ae

Browse files
committed
feat: implement JVM widget hosting, add neutral texture catalog, and enhance widget SDK
1 parent d25225c commit e3b88ae

16 files changed

Lines changed: 245 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
2323

2424
- **Widget refresh orchestration** — refresh now targets only the selected or event-affected widget, with a polling fallback when no event is available.
2525
- **Browser usage classification** — browser domain and hourly statistics now retain the browser name, so usage from Chrome, Edge, and other supported browsers is kept separate; the Browser Usage page also provides a browser filter.
26+
- **JVM widget hosting** — Java widget manifests are validated and launched through a managed `java -jar` runtime host with lifecycle cleanup instead of being rejected by the registry.
27+
- **Neutral texture catalog** — added a bundled, dependency-free texture catalog for the neutral skin with linen, paper, and grid patterns.
28+
- **Widget SDK migration** — the JavaScript template, TypeScript template, and Widget Dev Harness now use the Gateway-backed `WidgetClient` for standard reads, subscriptions, local API calls, and focus writes; the legacy channel remains only as an explicit compatibility surface.
2629
- **Permission revocation behavior** — revoked widget permissions now clear active subscriptions and emit a `widget-permission-revoked` event so external widgets can enter a degraded state.
2730
- **Skin asset handling** — selected images are validated and copied into the managed application `skins` directory instead of retaining arbitrary source paths.
2831
- **Third-party widget recovery** — failed widgets now show recovery guidance and a real remount/retry action.
@@ -57,12 +60,6 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5760
- Rust unit tests compile successfully, but execution is blocked on the current Windows environment by `0xc0000139 STATUS_ENTRYPOINT_NOT_FOUND` during test-process startup.
5861
- `git diff --check`: passed.
5962

60-
### Known Limitations
61-
62-
- Java/JVM widget hosting is not enabled yet and is explicitly rejected by the registry.
63-
- The bundled neutral texture catalog remains optional follow-up work; built-in color palettes are available.
64-
- Some legacy widgets still use compatibility APIs while migration to the Gateway continues.
65-
6663
---
6764

6865
## [2.2.0] - 2026-08-28

examples/third-party-widget-template/index.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export function createWidget() {
8686
container.appendChild(rootEl);
8787

8888
try {
89-
const rows = await context.channel.getTodayAppTotals();
89+
const rows = await context.client.query("metrics");
9090
const total = rows.reduce((acc, row) => acc + row.total_seconds, 0);
9191
const hours = (total / 3600).toFixed(1);
9292
usage.textContent = `Today tracked: ${hours} h`;
@@ -95,7 +95,7 @@ export function createWidget() {
9595
}
9696

9797
try {
98-
stopListening = await context.channel.onActiveWindowChanged((info) => {
98+
stopListening = await context.client.subscribe("active-window-changed", (info) => {
9999
title.textContent = `Sample Hello Widget · ${info.app_name || "Unknown"}`;
100100
});
101101
} catch (err) {
@@ -104,7 +104,7 @@ export function createWidget() {
104104

105105
// Example: call the local HTTP API through the widget bridge.
106106
try {
107-
const result = await context.channel.localApiCall({
107+
const result = await context.client.localApiCall({
108108
method: "GET",
109109
path: "/api/screen-time/today",
110110
scopes: ["screen-time:read"],

examples/third-party-widget-template/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,11 @@ export function createWidget(): WidgetInstance {
9898
button("Client queries/state", () => void runClientReads()),
9999
button("Legacy read channel", () => void runLegacyReads()),
100100
button("Todo write lifecycle", () => void runTodoWrites()),
101-
button("Local API call", () => void context.channel.localApiCall({
101+
button("Local API call", () => void context.client.localApiCall({
102102
method: "GET", path: "/api/screen-time/today", scopes: ["screen-time:read"],
103-
}).then((value) => write(output, "channel.localApiCall", value)).catch((error: unknown) => write(output, "local API error", String(error)))),
103+
}).then((value) => write(output, "client.localApiCall", value)).catch((error: unknown) => write(output, "local API error", String(error)))),
104104
button("Focus/settings writes", () => void Promise.all([
105-
context.channel.setFocusModeActive(false),
105+
context.client.setFocusModeActive(false),
106106
context.channel.setMonitoringActive(true),
107107
]).then(() => write(output, "channel settings", "ok")).catch((error: unknown) => write(output, "settings error", String(error)))),
108108
button("Request consent", () => void context.client.requestConsent("screen-time:read").then(() => write(output, "client.requestConsent", "ok")).catch((error: unknown) => write(output, "consent error", String(error)))),

src-tauri/src/commands/widget_runtime_cmd.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,24 @@ pub fn emit_widget_lifecycle(
548548
kernel.lifecycle_event(&request.widget_id, &request.event)
549549
}
550550

551+
#[tauri::command]
552+
pub fn start_jvm_widget(
553+
widget_id: String,
554+
widget_type: String,
555+
app: AppHandle,
556+
runtime: State<'_, crate::jvm_runtime::JvmRuntimeManager>,
557+
) -> Result<(), String> {
558+
runtime.start(&app, &widget_id, &widget_type)
559+
}
560+
561+
#[tauri::command]
562+
pub fn stop_jvm_widget(
563+
widget_id: String,
564+
runtime: State<'_, crate::jvm_runtime::JvmRuntimeManager>,
565+
) -> Result<(), String> {
566+
runtime.stop(&widget_id)
567+
}
568+
551569
// ── Error logs and runtime control ────────────────────────────
552570

553571
fn record_widget_error_inner(

src-tauri/src/jvm_runtime.rs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
use std::collections::HashMap;
2+
use std::path::PathBuf;
3+
use std::process::{Child, Command, Stdio};
4+
use std::sync::Mutex;
5+
6+
use tauri::AppHandle;
7+
8+
use crate::widget_registry::get_widget_by_type;
9+
10+
#[derive(Default)]
11+
pub struct JvmRuntimeManager {
12+
processes: Mutex<HashMap<String, Child>>,
13+
}
14+
15+
impl JvmRuntimeManager {
16+
fn runtime_jar(app: &AppHandle, widget_type: &str) -> Result<PathBuf, String> {
17+
let item = get_widget_by_type(app, widget_type)
18+
.ok_or_else(|| format!("widget type not found in registry: {widget_type}"))?;
19+
if !item
20+
.runtime_language
21+
.as_deref()
22+
.is_some_and(|language| language.eq_ignore_ascii_case("java"))
23+
{
24+
return Err(format!("widget is not a Java runtime: {widget_type}"));
25+
}
26+
let entry = item
27+
.runtime_entry
28+
.ok_or_else(|| "Java runtime entry is missing".to_string())?;
29+
let manifest_entry = item
30+
.entry
31+
.ok_or_else(|| "widget manifest entry is missing".to_string())?;
32+
let parent = PathBuf::from(manifest_entry)
33+
.parent()
34+
.map(PathBuf::from)
35+
.ok_or_else(|| "widget manifest has no parent directory".to_string())?;
36+
let jar = parent.join(entry);
37+
if !jar.is_file() {
38+
return Err(format!("Java runtime JAR not found: {}", jar.display()));
39+
}
40+
Ok(jar)
41+
}
42+
43+
pub fn start(&self, app: &AppHandle, widget_id: &str, widget_type: &str) -> Result<(), String> {
44+
self.stop(widget_id)?;
45+
let jar = Self::runtime_jar(app, widget_type)?;
46+
let mut command = Command::new("java");
47+
command
48+
.arg("-jar")
49+
.arg(&jar)
50+
.current_dir(jar.parent().unwrap_or_else(|| std::path::Path::new(".")))
51+
.stdin(Stdio::null())
52+
.stdout(Stdio::null())
53+
.stderr(Stdio::null());
54+
let child = command.spawn().map_err(|error| {
55+
format!(
56+
"failed to start JVM host; install Java {}: {error}",
57+
"runtime"
58+
)
59+
})?;
60+
self.processes
61+
.lock()
62+
.map_err(|error| error.to_string())?
63+
.insert(widget_id.to_string(), child);
64+
Ok(())
65+
}
66+
67+
pub fn stop(&self, widget_id: &str) -> Result<(), String> {
68+
let mut processes = self.processes.lock().map_err(|error| error.to_string())?;
69+
if let Some(mut child) = processes.remove(widget_id) {
70+
let _ = child.kill();
71+
let _ = child.wait();
72+
}
73+
Ok(())
74+
}
75+
}
76+
77+
impl Drop for JvmRuntimeManager {
78+
fn drop(&mut self) {
79+
if let Ok(processes) = self.processes.get_mut() {
80+
for (_, mut child) in processes.drain() {
81+
let _ = child.kill();
82+
let _ = child.wait();
83+
}
84+
}
85+
}
86+
}

src-tauri/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub mod monitor;
88
pub mod widget_gateway;
99
pub mod widget_kernel;
1010
pub mod widget_registry;
11+
pub mod jvm_runtime;
1112

1213
use std::path::{Path, PathBuf};
1314
use std::sync::{Arc, Mutex};
@@ -663,6 +664,7 @@ pub fn run() {
663664
widget_kernel::WidgetKernel::new(db_state.clone(), widget_call_rate_limiter.clone());
664665
widget_kernel.set_app_handle(app.handle().clone());
665666
app.manage(widget_kernel);
667+
app.manage(jvm_runtime::JvmRuntimeManager::default());
666668

667669
// Initialize extension bridge key on first run
668670
{
@@ -1270,6 +1272,8 @@ pub fn run() {
12701272
commands::set_widget_state,
12711273
commands::delete_widget_state,
12721274
commands::emit_widget_lifecycle,
1275+
commands::start_jvm_widget,
1276+
commands::stop_jvm_widget,
12731277
commands::record_widget_error,
12741278
commands::get_widget_error_log,
12751279
commands::clear_widget_error_log,

src-tauri/src/widget_registry.rs

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -567,17 +567,6 @@ pub fn load_third_party_widget_from_manifest_path(
567567
});
568568
};
569569

570-
if manifest
571-
.runtime
572-
.as_ref()
573-
.is_some_and(|runtime| runtime.language.eq_ignore_ascii_case("java"))
574-
{
575-
return Err(WidgetRegistryLoadError {
576-
path: manifest_path.display().to_string(),
577-
message: "java runtime is unsupported: no JVM host is enabled".to_string(),
578-
});
579-
}
580-
581570
let entry_path = parent_dir.join(&manifest.entry);
582571
if !entry_path.exists() {
583572
return Err(WidgetRegistryLoadError {
@@ -586,6 +575,30 @@ pub fn load_third_party_widget_from_manifest_path(
586575
});
587576
}
588577

578+
if let Some(runtime) = manifest.runtime.as_ref() {
579+
if runtime.language.eq_ignore_ascii_case("java") {
580+
let Some(runtime_entry) = runtime.entry.as_ref() else {
581+
return Err(WidgetRegistryLoadError {
582+
path: manifest_path.display().to_string(),
583+
message: "java runtime requires runtime.entry (a JAR file)".to_string(),
584+
});
585+
};
586+
let runtime_path = parent_dir.join(runtime_entry);
587+
if !runtime_path.is_file() {
588+
return Err(WidgetRegistryLoadError {
589+
path: manifest_path.display().to_string(),
590+
message: format!("java runtime entry not found: {}", runtime_path.display()),
591+
});
592+
}
593+
if runtime_path.extension().and_then(|ext| ext.to_str()) != Some("jar") {
594+
return Err(WidgetRegistryLoadError {
595+
path: manifest_path.display().to_string(),
596+
message: "java runtime.entry must point to a .jar file".to_string(),
597+
});
598+
}
599+
}
600+
}
601+
589602
// ── Signature verification (Phase B) ─────────────────────
590603
if let Some(expected_sig) = &manifest.signature {
591604
let entry_bytes = fs::read(&entry_path).map_err(|e| WidgetRegistryLoadError {

src/App.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import WidgetWindow from "./widgets/WidgetWindow";
88
import { useSettingsStore } from "./stores/settingsStore";
99
import { AnnouncerProvider } from "@/components/Announcer";
1010
import { getSkinPalette } from "@/utils/skinPalettes";
11+
import { getNeutralTexture } from "@/utils/skinTextures";
1112

1213
/**
1314
* Root component. Decides whether to render the main dashboard or a widget,
@@ -127,7 +128,7 @@ export default function App() {
127128
root.style.setProperty("--timelens-app-overlay", skin.app ? String(skin.appOverlay / 100) : "0");
128129
root.style.setProperty("--timelens-widget-overlay", skin.widget ? String(skin.widgetOverlay / 100) : "0");
129130
root.style.setProperty("--timelens-skin-pattern", activePalette === "neutral-texture"
130-
? "repeating-linear-gradient(135deg, rgba(255,255,255,.025) 0 1px, transparent 1px 7px)"
131+
? getNeutralTexture().css
131132
: "none");
132133
const palette = getSkinPalette(activePalette);
133134
const paletteVars: Record<string, string> = {

src/i18n/locales/en/widgets.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@
296296
"title": "Third-party Widget",
297297
"source": "Third-party",
298298
"loading": "Loading widget module...",
299+
"jvmRunning": "JVM widget host is running.",
299300
"recoveryHint": "Try revoking permissions, removing the widget, or importing an updated version.",
300301
"retry": "Retry",
301302
"loadErrorHint": "Check permissions and reload the widget.",

src/i18n/locales/zh-CN/widgets.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@
296296
"title": "第三方小组件",
297297
"source": "第三方",
298298
"loading": "正在加载小组件模块...",
299+
"jvmRunning": "JVM 小组件宿主正在运行。",
299300
"recoveryHint": "请尝试撤销权限、移除该小组件,或导入更新版本。",
300301
"retry": "重试",
301302
"loadErrorHint": "请检查权限并重新加载小组件。",

0 commit comments

Comments
 (0)