Skip to content

Commit 85d3d33

Browse files
committed
feat: 更新第三方小组件模板,添加 TypeScript 示例和 API 展示
feat: 修改 LLM 配置,支持自定义日期范围 feat: 添加 tauri-plugin-opener 以支持文件和目录打开功能 feat: 更新多语言支持,添加自定义范围和日期字段 feat: 在 LLM Insights 页面中实现自定义日期范围选择
1 parent 81c6877 commit 85d3d33

21 files changed

Lines changed: 329 additions & 46 deletions

File tree

examples/third-party-widget-template/README.md

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
# Third-party Widget Template
22

3-
This template demonstrates the minimum files required to run a third-party JS widget in TimeLens.
3+
This template is a complete TypeScript showcase for the rewritten TimeLens widget runtime. It exposes buttons for the current WidgetClient APIs, legacy channel APIs, consent, local API access, and reserved network/media methods.
44

55
## Files
66

77
- `manifest.json`: widget metadata and v4 runtime declaration
8-
- `index.js`: ESM widget entry implementing `createWidget().mount/unmount`
9-
- `index.ts`: TypeScript source for the same widget (optional)
8+
- `index.ts`: TypeScript ESM widget entry and API showcase
9+
- `dist/index.js`: compiled entry loaded by TimeLens
1010
- `types.d.ts`: TypeScript declarations describing the widget context API
1111
- `package.json`: build scripts; run `npm install && npm run build` to compile `index.ts` to `dist/index.js`
1212
- `tsconfig.json`: TypeScript compiler options
@@ -19,24 +19,37 @@ This template demonstrates the minimum files required to run a third-party JS wi
1919
"widget_type": "sample_hello",
2020
"name": "Sample Hello Widget",
2121
"publisher": "TimeLens Example",
22-
"entry": "index.js",
23-
"runtime": { "language": "javascript", "version": "ES2022", "entry": "index.js" },
22+
"entry": "dist/index.js",
23+
"runtime": { "language": "typescript", "version": "5.0", "entry": "dist/index.js" },
2424
"ui": { "model": "web-sandbox" },
2525
"capabilities": [
2626
"screen-time:read",
27-
"active-window:subscribe",
28-
"local-api:call"
27+
"todo:read",
28+
"todo:write",
29+
"browser:read",
30+
"settings:write",
31+
"local-api:call",
32+
"active-window:subscribe"
2933
]
3034
}
3135
```
3236

33-
In v4, capability strings are runtime scopes. Declare only the scopes required by
34-
the widget. The top-level `entry` must match `runtime.entry` for the current
35-
JavaScript loader.
37+
In v4, capability strings are runtime scopes. The top-level `entry` must match
38+
`runtime.entry`. This example declares all implemented scopes because it
39+
demonstrates every available API. Network and media calls only demonstrate their
40+
current unimplemented response.
41+
42+
## Build
43+
44+
```bash
45+
npm install
46+
npm run build
47+
```
3648

3749
## How to test
3850

39-
1. Copy this folder to your local TimeLens app data widgets directory:
51+
1. Build the package so `dist/index.js` exists.
52+
2. Copy this folder to your local TimeLens app data widgets directory:
4053
- `widgets/sample_hello/`
4154
2. Start TimeLens.
4255
3. Open Widget Center → Add Widgets.
@@ -54,4 +67,9 @@ For faster iteration, use the Widget Dev Harness (dev mode only):
5467
- The entry file must be valid ESM and export `createWidget()` or `mount()`.
5568
- Use `context.client.query(...)` for new Gateway-mediated data access.
5669
- Use `context.channel.localApiCall({ method, path, scopes })` only for the compatibility API.
70+
- `Client queries/state` exercises all query namespaces, browser activity, and state APIs.
71+
- `Todo write lifecycle` adds, toggles, reorders, and deletes a temporary todo.
72+
- `Legacy read channel` exercises the compatibility read methods.
73+
- `Focus/settings writes` and `Local API call` require their declared scopes.
74+
- `Test reserved network/media` is expected to report unimplemented providers.
5775
- See `docs/WIDGETS_DEV_GUIDE.md` for the current runtime guide and `docs/WIDGET_SDK_v2_MIGRATION.md` for v1/v2 migration.

examples/third-party-widget-template/README.zh-CN.md

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
# 第三方小组件模板
22

3-
该模板演示了在 TimeLens 中运行第三方 JS 小组件所需的最小文件集
3+
该模板是一个完整的 TypeScript 小组件示例,展示重写后的 TimeLens 运行时。组件包含按钮,可调用当前 WidgetClient、兼容 channel、同意、本地 API,以及网络/媒体预留接口
44

55
## 文件说明
66

77
- `manifest.json`:小组件元数据与 v4 运行时声明
8-
- `index.js`ESM 入口,需实现 `createWidget().mount/unmount`
9-
- `index.ts`:同一小组件的 TypeScript 源码(可选)
8+
- `index.ts`:TypeScript ESM 入口和完整 API 展示
9+
- `dist/index.js`:TimeLens 实际加载的编译产物
1010
- `package.json`:构建脚本;运行 `npm install && npm run build` 可将 `index.ts` 编译为 `dist/index.js`
1111
- `tsconfig.json`:TypeScript 编译选项
1212

@@ -18,18 +18,29 @@
1818
"widget_type": "sample_hello",
1919
"name": "Sample Hello Widget",
2020
"publisher": "TimeLens Example",
21-
"entry": "index.js",
22-
"runtime": { "language": "javascript", "version": "ES2022", "entry": "index.js" },
21+
"entry": "dist/index.js",
22+
"runtime": { "language": "typescript", "version": "5.0", "entry": "dist/index.js" },
2323
"ui": { "model": "web-sandbox" },
24-
"capabilities": ["screen-time:read", "active-window:subscribe"]
24+
"capabilities": [
25+
"screen-time:read", "todo:read", "todo:write", "browser:read",
26+
"settings:write", "local-api:call", "active-window:subscribe"
27+
]
2528
}
2629
```
2730

28-
v4 中 capability 字符串就是运行时 Scope,只声明小组件真正需要的 Scope。当前 JavaScript 加载器要求顶层 `entry``runtime.entry` 一致。
31+
v4 中 capability 字符串就是运行时 Scope。示例声明全部已实现的 Scope,因为它会展示所有接口。网络和媒体调用仅用于展示当前尚未实现的返回结果。
32+
33+
## 构建
34+
35+
```bash
36+
npm install
37+
npm run build
38+
```
2939

3040
## 测试步骤
3141

32-
1. 将本目录复制到本机 TimeLens 应用数据 widgets 目录,例如:
42+
1. 先构建,确保生成 `dist/index.js`
43+
2. 将本目录复制到本机 TimeLens 应用数据 widgets 目录,例如:
3344
- `widgets/sample_hello/`
3445
2. 启动 TimeLens。
3546
3. 打开小组件中心 → 添加小组件。
@@ -47,4 +58,9 @@ v4 中 capability 字符串就是运行时 Scope,只声明小组件真正需
4758
- 入口文件必须是有效 ESM,且导出 `createWidget()``mount()`
4859
- 新小组件通过 `context.client.query(...)` 使用 Gateway 数据接口。
4960
- 仅在兼容旧 API 时使用 `context.channel.localApiCall({ method, path, scopes })`
61+
- “Client queries/state” 会调用所有查询命名空间、浏览器活动和状态接口。
62+
- “Todo write lifecycle” 会新增、切换、排序并删除一个临时待办。
63+
- “Legacy read channel” 会调用兼容读取接口。
64+
- “Focus/settings writes”和“Local API call”需要对应 Scope。
65+
- “Test reserved network/media”预期会显示 Provider 尚未实现。
5066
- 当前运行时指南见 `docs/WIDGETS_DEV_GUIDE.zh-CN.md`,v1/v2 迁移见 `docs/WIDGET_SDK_v2_MIGRATION.md`
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import type {
2+
WidgetClient,
3+
WidgetInstance,
4+
WidgetQueryNamespace,
5+
} from "./types";
6+
7+
const queryNamespaces: WidgetQueryNamespace[] = [
8+
"metrics", "sessions", "categories", "projects", "tags",
9+
"goals", "rules", "focus", "todos", "browser",
10+
];
11+
12+
function write(output: HTMLElement, label: string, value: unknown): void {
13+
const line = document.createElement("div");
14+
line.textContent = `${label}: ${JSON.stringify(value)}`;
15+
output.appendChild(line);
16+
}
17+
18+
function button(label: string, onClick: () => void): HTMLButtonElement {
19+
const element = document.createElement("button");
20+
element.textContent = label;
21+
element.style.cssText = "padding:6px 8px;border:0;border-radius:6px;cursor:pointer;";
22+
element.addEventListener("click", onClick);
23+
return element;
24+
}
25+
26+
export function createWidget(): WidgetInstance {
27+
let root: HTMLDivElement | null = null;
28+
let client: WidgetClient | null = null;
29+
let subscription: number | null = null;
30+
31+
return {
32+
async mount(container, context) {
33+
client = context.client;
34+
root = document.createElement("div");
35+
root.style.cssText = "box-sizing:border-box;height:100%;overflow:auto;padding:14px;color:#e5eaf6;background:#182033;font-family:sans-serif;font-size:12px;";
36+
37+
const title = document.createElement("h3");
38+
title.textContent = `Widget API Showcase: ${context.widgetType}`;
39+
title.style.margin = "0 0 10px";
40+
41+
const controls = document.createElement("div");
42+
controls.style.cssText = "display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px;";
43+
const output = document.createElement("pre");
44+
output.style.cssText = "white-space:pre-wrap;word-break:break-word;margin:0;padding:8px;background:#0f1524;";
45+
root.append(title, controls, output);
46+
container.appendChild(root);
47+
48+
const runClientReads = async () => {
49+
output.textContent = "Running WidgetClient query and state APIs...\n";
50+
for (const namespace of queryNamespaces) {
51+
try { write(output, `client.query(${namespace})`, await context.client.query(namespace)); }
52+
catch (error) { write(output, `client.query(${namespace}) error`, String(error)); }
53+
}
54+
try { write(output, "client.getBrowserActivity", await context.client.getBrowserActivity()); }
55+
catch (error) { write(output, "client.getBrowserActivity error", String(error)); }
56+
try {
57+
await context.client.setState("showcase", "visited");
58+
write(output, "client.getState", await context.client.getState("showcase"));
59+
await context.client.deleteState("showcase");
60+
write(output, "client.deleteState", "ok");
61+
} catch (error) { write(output, "client state error", String(error)); }
62+
};
63+
64+
const runLegacyReads = async () => {
65+
output.textContent = "Running legacy channel read APIs...\n";
66+
const calls: Array<[string, () => Promise<unknown>]> = [
67+
["channel.getTodayAppTotals", () => context.channel.getTodayAppTotals()],
68+
["channel.getAppTotalsInRange", () => context.channel.getAppTotalsInRange("2026-01-01", "2026-12-31")],
69+
["channel.getCategoryTotalsInRange", () => context.channel.getCategoryTotalsInRange("2026-01-01", "2026-12-31")],
70+
["channel.getHourlyForDate", () => context.channel.getHourlyForDate("2026-08-27")],
71+
["channel.getRecentDailyTotalsRange", () => context.channel.getRecentDailyTotalsRange("2026-08-01", "2026-08-27")],
72+
["channel.getAppCategoryMap", () => context.channel.getAppCategoryMap()],
73+
["channel.getTodos", () => context.channel.getTodos()],
74+
["channel.getUsageGoals", () => context.channel.getUsageGoals()],
75+
["channel.listFocusSessions", () => context.channel.listFocusSessions()],
76+
];
77+
for (const [label, call] of calls) {
78+
try { write(output, label, await call()); }
79+
catch (error) { write(output, `${label} error`, String(error)); }
80+
}
81+
};
82+
83+
const runTodoWrites = async () => {
84+
output.textContent = "Running todo write APIs...\n";
85+
try {
86+
const todo = await context.client.addTodo("TimeLens WidgetClient showcase");
87+
write(output, "client.addTodo", todo);
88+
await context.client.toggleTodo(todo.id);
89+
write(output, "client.toggleTodo", "ok");
90+
await context.client.reorderTodos([todo.id]);
91+
write(output, "client.reorderTodos", "ok");
92+
await context.client.deleteTodo(todo.id);
93+
write(output, "client.deleteTodo", "ok");
94+
} catch (error) { write(output, "client todo error", String(error)); }
95+
};
96+
97+
controls.append(
98+
button("Client queries/state", () => void runClientReads()),
99+
button("Legacy read channel", () => void runLegacyReads()),
100+
button("Todo write lifecycle", () => void runTodoWrites()),
101+
button("Local API call", () => void context.channel.localApiCall({
102+
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)))),
104+
button("Focus/settings writes", () => void Promise.all([
105+
context.channel.setFocusModeActive(false),
106+
context.channel.setMonitoringActive(true),
107+
]).then(() => write(output, "channel settings", "ok")).catch((error: unknown) => write(output, "settings error", String(error)))),
108+
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)))),
109+
button("Test reserved network/media", () => void Promise.allSettled([
110+
context.client.fetch("https://example.com"),
111+
context.client.loadMedia("https://example.com/image.png"),
112+
]).then((results) => write(output, "client.fetch/loadMedia", results.map((result) => result.status)))),
113+
);
114+
115+
try {
116+
subscription = await context.client.subscribe("active-window-changed", (payload) => {
117+
title.textContent = `Widget API Showcase: ${JSON.stringify(payload)}`;
118+
});
119+
write(output, "client.subscribe", `handle ${subscription}`);
120+
} catch (error) { write(output, "client.subscribe error", String(error)); }
121+
},
122+
123+
async unmount() {
124+
if (subscription !== null && client) await client.unsubscribe(subscription);
125+
subscription = null;
126+
root?.remove();
127+
root = null;
128+
client = null;
129+
},
130+
};
131+
}
Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,28 @@
11
{
2-
"manifest_version": "v2",
2+
"manifest_version": "v4",
33
"widget_type": "sample_hello",
44
"name": "Sample Hello Widget",
5-
"description": "A minimal third-party widget template for TimeLens v2",
6-
"entry": "index.js",
5+
"description": "A complete TypeScript WidgetClient API showcase",
6+
"publisher": "TimeLens Example",
7+
"entry": "dist/index.js",
78
"default_size": {
89
"width": 360,
910
"height": 240
1011
},
12+
"runtime": {
13+
"language": "typescript",
14+
"version": "5.0",
15+
"entry": "dist/index.js"
16+
},
17+
"ui": { "model": "web-sandbox" },
1118
"capabilities": [
12-
{ "capability": "read_metrics", "permission": "screen-time:read" },
13-
{ "capability": "automation_trigger", "permission": "active-window:subscribe" },
14-
{ "capability": "local_api_call", "permission": "local-api:call" }
19+
"screen-time:read",
20+
"todo:read",
21+
"todo:write",
22+
"browser:read",
23+
"settings:write",
24+
"local-api:call",
25+
"active-window:subscribe"
1526
],
16-
"sdk_version": "2.0.0"
27+
"sdk_version": "4.0.0"
1728
}

src-tauri/Cargo.lock

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ macos-private-api = ["tauri/macos-private-api"]
2424
tauri = { version = "2", features = ["tray-icon", "image-png", "image-ico"] }
2525
tauri-plugin-autostart = "2"
2626
tauri-plugin-shell = "2"
27+
tauri-plugin-opener = "2"
2728
tauri-plugin-global-shortcut = "2"
2829
tauri-plugin-notification = "2"
2930
tauri-plugin-updater = "2"

src-tauri/capabilities/default.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
"global-shortcut:default",
3434
"notification:default",
3535
"shell:allow-open",
36+
"opener:allow-open-path",
37+
"opener:allow-reveal-item-in-dir",
3638
"updater:default",
3739
"dialog:allow-open",
3840
"dialog:allow-save",

src-tauri/src/commands/llm_cmd.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use crate::llm::{self, config::LlmConfig};
2-
use tauri::{AppHandle, Emitter, Manager};
3-
use tauri_plugin_shell::ShellExt;
2+
use tauri::{AppHandle, Emitter};
3+
use tauri_plugin_opener::OpenerExt;
44

55
/// Load the LLM configuration from the local TOML file.
66
#[tauri::command]
@@ -32,8 +32,8 @@ pub fn get_llm_config_path(app_handle: AppHandle) -> Result<String, String> {
3232
pub async fn open_llm_config_file(app_handle: AppHandle) -> Result<(), String> {
3333
let path = llm::config_path(&app_handle)?;
3434
app_handle
35-
.shell()
36-
.open(path.to_string_lossy(), None)
35+
.opener()
36+
.open_path(path.to_string_lossy(), None::<&str>)
3737
.map_err(|e| e.to_string())
3838
}
3939

@@ -43,7 +43,7 @@ pub async fn open_llm_config_dir(app_handle: AppHandle) -> Result<(), String> {
4343
let path = llm::config_path(&app_handle)?;
4444
let dir = path.parent().ok_or("Failed to resolve config directory")?;
4545
app_handle
46-
.shell()
47-
.open(dir.to_string_lossy(), None)
46+
.opener()
47+
.open_path(dir.to_string_lossy(), None::<&str>)
4848
.map_err(|e| e.to_string())
4949
}

src-tauri/src/db/llm_conversations.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ use serde::{Deserialize, Serialize};
66
pub struct StoredChatMessage {
77
pub role: String,
88
pub content: String,
9+
#[serde(skip_serializing_if = "Option::is_none")]
10+
pub hidden: Option<bool>,
911
}
1012

1113
/// Full conversation record.

0 commit comments

Comments
 (0)