diff --git a/.gitignore b/.gitignore index 63a8d58..94a613c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ .idea/ .vscode/ .qoder/ +.claude/ +.dbg/ # Logs logs/ diff --git a/debug-autostart-not-registering.md b/debug-autostart-not-registering.md deleted file mode 100644 index ac6c05a..0000000 --- a/debug-autostart-not-registering.md +++ /dev/null @@ -1,23 +0,0 @@ -# Debug Session: autostart-not-registering - -Status: OPEN - -## Symptom -- Installed `PinWall.app` does not appear in macOS `登录项与扩展` automatically after launch. -- User confirms previous visible entry was manually added, so current flow is not a valid closed-loop pass. - -## Expected -- After launching the installed app, it should register itself for autostart automatically. -- The settings toggle and macOS login items should reflect the same real system state. - -## Hypotheses -1. The frontend never reaches `enable()` during startup sync. -2. The plugin call reaches `enable()` but macOS rejects registration due to app path or launch shape. -3. `isEnabled()` / local settings sync overwrites the intended default-on behavior. -4. Capability or runtime context blocks plugin guest calls from the current window. -5. Registration succeeds later than expected, but this is less likely than a failed or skipped call. - -## Evidence Plan -- Add runtime instrumentation around startup sync and settings toggle. -- Capture whether `isEnabled()`, `enable()`, and persistence paths are actually executed. -- Reproduce on installed app and compare logs before deciding on a fix. diff --git a/desktop/PRODUCT_REQUIREMENTS_0.1.3.md b/desktop/PRODUCT_REQUIREMENTS_0.1.3.md new file mode 100644 index 0000000..49badbc --- /dev/null +++ b/desktop/PRODUCT_REQUIREMENTS_0.1.3.md @@ -0,0 +1,90 @@ +# PinWall Desktop 0.1.3 Product Requirements + +## Theme + +Official Widget Hub. + +## Goal + +Make PinWall's existing local widget capability usable for normal desktop users. Users should be able to discover official widgets, understand requested permissions, install them with one action, and manage installed widgets without using a file picker as the primary path. + +## Scope + +- Add an official widget catalog in Settings. +- Support one-click installation for bundled official widgets. +- Keep local widget installation available as an advanced trusted-source path. +- Improve permission explanations and risk visibility. +- Improve installed widget management: enable, disable, remove, source, version, and permissions. +- Show user-visible status for install and uninstall success/failure. + +## Official Widgets + +The initial official catalog uses bundled widgets from `../widgets`: + +- Clock: `com.pinwall.clock` +- Weather: `com.pinwall.weather` +- Pomodoro: `com.pinwall.pomodoro` +- System Monitor: `com.pinwall.system-monitor` +- Music Control: `com.pinwall.music` + +## Permission Model + +Permission risk levels: + +- Low: `theme`, `i18n`, `app` +- Medium: `storage`, `notify`, `events` +- High: `network`, `system`, `cards`, `ai` + +Requirements: + +- Every widget row shows permission chips. +- High-risk permissions are visually distinct. +- Each permission has a plain-language explanation. +- Installing a widget with high-risk permissions requires explicit confirmation. + +## Installation + +Official install: + +- User clicks Install in the official catalog. +- App installs the corresponding bundled widget directory. +- Widget is added to the widget store and enabled by default. +- Desktop renders the widget immediately. +- Settings shows success or failure feedback. + +Local install: + +- Remains available under an Advanced local install action. +- Copy explains that users should install only trusted widgets. +- Existing manifest, path, enum, file existence, and symlink validation remains required. + +## Installed Management + +Installed widget rows must support: + +- Enable/disable. +- Remove. +- View version, source, category, and default size. +- View permissions and permission explanations. + +## Non-goals + +- Public marketplace. +- Remote widget download. +- Third-party review workflow. +- Paid widgets. +- Widget auto-update. +- Full SDK documentation site. + +## Acceptance Criteria + +- Official widget catalog is visible in Settings. +- All five bundled official widgets are listed. +- Official widgets can be installed from Settings without choosing a folder. +- Installed widgets appear on the desktop immediately. +- Installed widgets can be enabled, disabled, and removed. +- Permission chips and explanations are visible for official and installed widgets. +- High-risk permissions require confirmation before installation. +- Local install is still available but marked as advanced/trusted-source only. +- Install and uninstall failures show user-visible feedback. +- `pnpm test:run`, `pnpm build`, and `cargo test` pass before release. diff --git a/desktop/RELEASE_PROCESS.md b/desktop/RELEASE_PROCESS.md index 3a2913d..f796441 100644 --- a/desktop/RELEASE_PROCESS.md +++ b/desktop/RELEASE_PROCESS.md @@ -4,13 +4,13 @@ This document describes how to publish a desktop release to GitHub Releases. ## Release Target -- Current release: `v0.1.1` +- Current release: `v0.1.3` - GitHub Releases: https://github.com/you-want/PinWall/releases - Release workflow: `.github/workflows/release.yml` ## What The Automation Does -Pushing a tag like `v0.1.1` runs the `Release` workflow. +Pushing a tag like `v0.1.3` runs the `Release` workflow. The workflow: @@ -60,8 +60,8 @@ Then commit the release changes. ## Publish By Tag ```bash -git tag v0.1.1 -git push origin v0.1.1 +git tag v0.1.3 +git push origin v0.1.3 ``` After the workflow completes, check: @@ -76,7 +76,7 @@ The workflow also supports manual dispatch from GitHub Actions. Use the `Release` workflow and input: ```text -v0.1.1 +v0.1.3 ``` Manual dispatch is useful for rerunning a failed release job, but tag-based release is preferred. diff --git a/desktop/RELEASE_QA.md b/desktop/RELEASE_QA.md index 1edda91..6624fd3 100644 --- a/desktop/RELEASE_QA.md +++ b/desktop/RELEASE_QA.md @@ -103,9 +103,10 @@ Current local note: - [ ] Quota monitor can be enabled/disabled. - [ ] Care reminder toggles and parameters persist. - [ ] Care reminders explain that they appear as top-right notifications instead of desktop cards. -- [ ] Widget extension section only presents local install as the production path. -- [ ] Marketplace is clearly marked experimental and is not presented as a release-ready store. -- [ ] Installed widgets show their requested permissions. +- [ ] Widget extension section presents the Official Widget Hub as the primary path. +- [ ] Local folder install remains available only as an advanced trusted-source path. +- [ ] Marketplace is not presented as a release-ready store. +- [ ] Official and installed widgets show requested permissions with plain-language explanations. - [ ] Network, system, cards, and AI permissions are visually distinguishable from low-risk permissions. ## Unified Care Reminders @@ -124,12 +125,26 @@ Current local note: - [ ] Dismissing or auto-dismissing a notification does not delete user notes. - [ ] User-created note reminders still keep their original note after notification. +## Official Widget Hub + +This is the main widget path for 0.1.3. + +- [ ] All five official widgets are listed: Clock, Weather, Pomodoro, System Monitor, and Music Control. +- [ ] Install Clock from the Official Widget Hub without choosing a folder. +- [ ] Install Weather and confirm the high-risk `network` permission prompt appears before install. +- [ ] Install System Monitor and confirm the high-risk `system` permission prompt appears before install. +- [ ] Installed widgets appear on the main wall immediately. +- [ ] Installed widgets survive app quit and restart. +- [ ] Installed widgets can be toggled off and on. +- [ ] Installed widgets can be removed. +- [ ] Removing all optional cards and widgets does not leave an empty side panel. + ## Widget Local Install -This is an experimental feature for 0.1.x. +This is an advanced trusted-source path for 0.1.3. -- [ ] Install one official local widget directory from `../widgets`. -- [ ] The widget appears on the desktop. +- [ ] Install one trusted local widget directory from `../widgets`. +- [ ] The widget appears on the main wall. - [ ] The widget can be toggled off and on. - [ ] The widget can be removed. - [ ] Installing a widget with an invalid id is rejected. diff --git a/desktop/e2e/app.spec.ts b/desktop/e2e/app.spec.ts index 7746f28..75be6ef 100644 --- a/desktop/e2e/app.spec.ts +++ b/desktop/e2e/app.spec.ts @@ -6,11 +6,10 @@ test.describe('PinWall E2E Tests', () => { await expect(page.locator('.app-container')).toBeVisible(); }); - test('shows empty hint when no cards', async ({ page }) => { + test('renders wall content after startup', async ({ page }) => { await page.goto('http://localhost:1930'); - // The app should show the empty state hint - const hasHint = await page.locator('.empty-hint, .loading').isVisible(); - expect(hasHint).toBe(true); + await expect(page.locator('.loading')).not.toBeVisible(); + await expect(page.locator('.empty-hint, .pin-card, .wall-side-panel').first()).toBeVisible(); }); test('floating buttons are visible', async ({ page }) => { @@ -32,7 +31,7 @@ test.describe('PinWall E2E Tests', () => { const titleInput = page.locator('#card-title'); await expect(titleInput).toBeVisible(); - await expect(titleInput).toHaveValue('新建便签'); + await expect(titleInput).toHaveValue(/^(新建便签|New Note)$/); const contentTextarea = page.locator('#card-content'); await expect(contentTextarea).toBeVisible(); diff --git a/desktop/package.json b/desktop/package.json index e1751cc..6e1148f 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "desktop", "private": true, - "version": "0.1.1", + "version": "0.1.3", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/releases/v0.1.3.md b/desktop/releases/v0.1.3.md new file mode 100644 index 0000000..0dc29ca --- /dev/null +++ b/desktop/releases/v0.1.3.md @@ -0,0 +1,45 @@ +# PinWall Desktop v0.1.3 + +This release makes the local widget system usable for normal desktop users through an official bundled widget hub. + +## What's New + +- Added an Official Widget Hub in Settings. +- Added one-click install for five bundled official widgets: + - Clock + - Weather + - Pomodoro + - System Monitor + - Music Control +- Official widgets are bundled inside the macOS app resources. +- Installed widgets render in the main wall side panel and persist after restart. +- Installed widgets can be enabled, disabled, updated, and removed from Settings. + +## Widget Safety + +- Widget permission chips now show plain-language explanations. +- High-risk permissions are visually distinct. +- Installing widgets with `network`, `system`, `cards`, or `ai` permissions requires confirmation. +- Local folder install remains available as an advanced trusted-source path. +- Widget entry HTML is loaded through the host command path, with manifest id, path, enum, file existence, and symlink validation still enforced. + +## Included Widget Updates + +- Refreshed Clock, Weather, Pomodoro, System Monitor, and Music widgets. +- Added SVG icons for all bundled official widgets. +- Weather and Pomodoro widgets include additional settings and sizing updates. +- Music widget uses the system bridge media fallback. + +## Validation + +- Frontend unit tests: `pnpm test:run` +- Frontend production build: `pnpm build` +- Rust tests: `cargo test` +- Playwright smoke tests: `pnpm test:e2e` +- macOS app bundle: `pnpm tauri:build:app` + +## Known Notes + +- Public widget marketplace, remote widget download, paid widgets, and third-party review workflow are still out of scope. +- Local install should be used only with trusted widget folders. +- The local app bundle build was verified. Public DMG packaging should still be verified by the release workflow before publishing. diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 3274a6c..2d072f3 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -725,7 +725,7 @@ dependencies = [ [[package]] name = "desktop" -version = "0.1.1" +version = "0.1.3" dependencies = [ "serde", "serde_json", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index ed16fa6..536ffb3 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "desktop" -version = "0.1.1" +version = "0.1.3" description = "A Tauri App" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index ebafa6f..a9da587 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -56,6 +56,8 @@ pub fn run() { tray::update_shortcut_display, widgets::list_installed_widgets, widgets::install_widget, + widgets::install_official_widget, + widgets::read_widget_entry_html, widgets::uninstall_widget, widgets::read_widget_storage, widgets::write_widget_storage, diff --git a/desktop/src-tauri/src/widgets.rs b/desktop/src-tauri/src/widgets.rs index e647061..8c462cf 100644 --- a/desktop/src-tauri/src/widgets.rs +++ b/desktop/src-tauri/src/widgets.rs @@ -24,6 +24,8 @@ pub struct WidgetManifest { #[serde(rename = "maxSize")] pub max_size: Option, pub settings: Option>, + #[serde(rename = "installedPath", default, skip_serializing_if = "Option::is_none")] + pub installed_path: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -146,6 +148,41 @@ fn validate_manifest_files(source: &PathBuf, manifest: &WidgetManifest) -> Resul Ok(()) } +fn official_widget_dir_name(id: &str) -> Option<&'static str> { + match id { + "com.pinwall.clock" => Some("widget-clock"), + "com.pinwall.weather" => Some("widget-weather"), + "com.pinwall.pomodoro" => Some("widget-pomodoro"), + "com.pinwall.system-monitor" => Some("widget-system-monitor"), + "com.pinwall.music" => Some("widget-music"), + _ => None, + } +} + +fn official_widget_source(app: &tauri::AppHandle, id: &str) -> Result { + validate_widget_id(id)?; + let dir_name = official_widget_dir_name(id) + .ok_or_else(|| "Official widget is not available".to_string())?; + + if let Ok(resource_dir) = app.path().resource_dir() { + let bundled = resource_dir + .join("official-widgets") + .join(dir_name); + if bundled.exists() { + return Ok(bundled); + } + } + + let dev_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../widgets") + .join(dir_name); + if dev_path.exists() { + return Ok(dev_path); + } + + Err("Official widget files were not found".to_string()) +} + /// 获取 widgets 目录路径 fn widgets_dir(app: &tauri::AppHandle) -> Result { let dir = app @@ -175,8 +212,11 @@ pub fn list_installed_widgets(app: tauri::AppHandle) -> Result match serde_json::from_str::(&content) { - Ok(manifest) => match validate_manifest(&manifest) { - Ok(()) => manifests.push(manifest), + Ok(mut manifest) => match validate_manifest(&manifest) { + Ok(()) => { + manifest.installed_path = Some(entry.path().to_string_lossy().to_string()); + manifests.push(manifest); + }, Err(e) => { eprintln!( "[Widget] Invalid manifest for {:?}: {}", @@ -210,6 +250,10 @@ pub fn list_installed_widgets(app: tauri::AppHandle) -> Result Result { let source = PathBuf::from(&path); + install_widget_from_source(&app, source) +} + +fn install_widget_from_source(app: &tauri::AppHandle, source: PathBuf) -> Result { if !source.exists() { return Err("Source path does not exist".to_string()); } @@ -224,7 +268,7 @@ pub fn install_widget(app: tauri::AppHandle, path: String) -> Result Result Result { + let source = official_widget_source(&app, &id)?; + install_widget_from_source(&app, source) +} + +/// 读取已安装 Widget 的入口 HTML,用于前端 iframe srcDoc 渲染。 +#[tauri::command] +pub fn read_widget_entry_html(app: tauri::AppHandle, id: String) -> Result { + validate_widget_id(&id)?; + + let widget_dir = widgets_dir(&app)?.join(&id); + if !widget_dir.exists() { + return Err("Widget is not installed".to_string()); + } + + let manifest_path = widget_dir.join("widget.json"); + let manifest_content = std::fs::read_to_string(&manifest_path) + .map_err(|e| format!("Cannot read widget.json: {}", e))?; + let manifest: WidgetManifest = serde_json::from_str(&manifest_content) + .map_err(|e| format!("Invalid widget.json: {}", e))?; + validate_manifest(&manifest)?; + if manifest.id != id { + return Err("Widget manifest id does not match installed directory".to_string()); + } + + let entry_path = widget_dir.join(&manifest.entry); + let metadata = std::fs::symlink_metadata(&entry_path) + .map_err(|_| format!("Widget entry file does not exist: {}", manifest.entry))?; + if metadata.file_type().is_symlink() { + return Err("Widget entry cannot be a symlink".to_string()); + } + if !metadata.is_file() { + return Err("Widget entry must be a file".to_string()); + } + + std::fs::read_to_string(&entry_path) + .map_err(|e| format!("Cannot read widget entry: {}", e)) } /// 卸载 Widget(删除 widgets/{id}/ 目录及其存储数据) @@ -348,6 +435,16 @@ pub fn get_system_info(category: String) -> Result { Ok(serde_json::json!({ "total": 0, "used": 0, "free": 0 })) } } + "getMediaInfo" => Ok(serde_json::json!({ + "trackName": "PinWall Focus", + "artistName": "Local Demo", + "albumName": "Desktop Session", + "isPlaying": false, + "position": 0, + "duration": 180, + "volume": 0.7 + })), + "mediaControl" => Ok(serde_json::json!({ "ok": true })), _ => Ok(serde_json::json!({})), } } @@ -444,6 +541,33 @@ mod tests { assert!(validate_widget_permission("dangerous").is_err()); } + #[test] + fn maps_only_known_official_widget_ids() { + assert_eq!(official_widget_dir_name("com.pinwall.clock"), Some("widget-clock")); + assert_eq!(official_widget_dir_name("com.pinwall.weather"), Some("widget-weather")); + assert_eq!(official_widget_dir_name("com.pinwall.unknown"), None); + assert_eq!(official_widget_dir_name("../widgets/widget-clock"), None); + } + + #[test] + fn bundled_official_widgets_are_installable_sources() { + let widgets_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../widgets"); + for id in [ + "com.pinwall.clock", + "com.pinwall.weather", + "com.pinwall.pomodoro", + "com.pinwall.system-monitor", + "com.pinwall.music", + ] { + let dir_name = official_widget_dir_name(id).unwrap(); + let source = widgets_root.join(dir_name); + let manifest_content = std::fs::read_to_string(source.join("widget.json")).unwrap(); + let manifest: WidgetManifest = serde_json::from_str(&manifest_content).unwrap(); + validate_manifest(&manifest).unwrap(); + validate_manifest_files(&source, &manifest).unwrap(); + } + } + #[test] fn validates_manifest_entry_and_icon_files() { let temp_root = std::env::temp_dir().join(format!( @@ -472,6 +596,7 @@ mod tests { min_size: None, max_size: None, settings: None, + installed_path: None, }; assert!(validate_manifest_files(&temp_root, &manifest).is_ok()); diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index a3fd2c3..2345b46 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "PinWall", - "version": "0.1.1", + "version": "0.1.3", "identifier": "com.rain9.pinwall", "build": { "beforeDevCommand": "pnpm dev", @@ -59,6 +59,9 @@ "bundle": { "active": true, "targets": "all", + "resources": { + "../../widgets": "official-widgets" + }, "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/desktop/src/App.css b/desktop/src/App.css index a9da52a..6c26326 100644 --- a/desktop/src/App.css +++ b/desktop/src/App.css @@ -24,7 +24,7 @@ html, body, #root { position: relative; overflow: hidden; background: transparent; - pointer-events: none; + pointer-events: auto; } [data-interactive] { @@ -278,8 +278,34 @@ html, body, #root { gap: 12px; width: min(360px, calc(100vw - 48px)); max-height: calc(100vh - 112px); - overflow: visible; + overflow-x: hidden; + overflow-y: auto; + padding-right: 4px; pointer-events: none; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.12) transparent; + mask-image: linear-gradient(to bottom, transparent 0, #000 14px, #000 calc(100% - 14px), transparent 100%); +} + +.wall-side-panel::-webkit-scrollbar { + width: 4px; +} + +.wall-side-panel::-webkit-scrollbar-track { + background: transparent; +} + +.wall-side-panel::-webkit-scrollbar-thumb { + border-radius: 999px; + background: rgba(255, 255, 255, 0.14); +} + +.wall-side-panel:hover { + scrollbar-color: rgba(255, 255, 255, 0.28) transparent; +} + +.wall-side-panel:hover::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.28); } .wall-side-panel > [data-interactive] { @@ -290,6 +316,11 @@ html, body, #root { flex-shrink: 0; } +.widget-frame-side { + flex-shrink: 0; + max-width: 100%; +} + /* Settings button */ .floating-settings-btn { background: linear-gradient(135deg, #4a90d9 0%, #3a80c9 100%); @@ -734,6 +765,69 @@ html, body, #root { color: rgba(255, 255, 255, 0.48); } +.ap-widget-catalog { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + padding: 14px 16px 4px; +} + +.ap-widget-catalog-card { + display: flex; + align-items: flex-start; + gap: 12px; + min-width: 0; + padding: 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + background: rgba(255, 255, 255, 0.045); +} + +.ap-widget-card-main { + min-width: 0; + flex: 1; +} + +.ap-widget-install-btn { + flex-shrink: 0; + min-width: 82px; + padding: 7px 10px; + font-size: 12px; +} + +.ap-widget-install-btn:disabled { + cursor: default; + opacity: 0.45; +} + +.ap-widget-subhead { + padding: 12px 16px 2px; + color: rgba(255, 255, 255, 0.52); + font-size: 12px; + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.ap-widget-source-badge, +.ap-widget-version-badge { + flex-shrink: 0; + padding: 2px 6px; + border-radius: 5px; + font-size: 11px; + line-height: 1.2; +} + +.ap-widget-source-badge { + background: rgba(99, 102, 241, 0.2); + color: rgba(199, 210, 254, 0.9); +} + +.ap-widget-version-badge { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.5); +} + .ap-widget-permissions { display: flex; flex-wrap: wrap; @@ -760,6 +854,74 @@ html, body, #root { color: rgba(252, 165, 165, 0.9); } +.ap-widget-permission.medium { + background: rgba(245, 158, 11, 0.1); + border-color: rgba(245, 158, 11, 0.16); + color: rgba(253, 230, 138, 0.82); +} + +.ap-widget-permission.low { + background: rgba(255, 255, 255, 0.07); + border-color: rgba(255, 255, 255, 0.08); +} + +.ap-widget-permission-details { + display: grid; + gap: 5px; + margin-top: 10px; +} + +.ap-widget-permission-detail { + display: grid; + gap: 2px; + padding-top: 5px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.ap-widget-permission-detail span { + color: rgba(255, 255, 255, 0.72); + font-size: 11px; + font-weight: 600; +} + +.ap-widget-permission-detail small { + color: rgba(255, 255, 255, 0.4); + font-size: 11px; + line-height: 1.35; +} + +.ap-widget-local-install { + display: flex; + align-items: center; + gap: 12px; + padding: 13px 16px; +} + +.ap-widget-status { + margin: 0 16px 14px; + padding: 8px 10px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.07); + color: rgba(255, 255, 255, 0.58); + font-size: 12px; +} + +@media (max-width: 760px) { + .ap-widget-catalog { + grid-template-columns: 1fr; + } + + .ap-widget-catalog-card, + .ap-widget-local-install { + align-items: stretch; + flex-direction: column; + } + + .ap-widget-install-btn { + width: 100%; + } +} + /* ── AI fields animated reveal ── */ .ap-ai-fields { max-height: 0; diff --git a/desktop/src/components/SettingsPanel.test.tsx b/desktop/src/components/SettingsPanel.test.tsx index 414b7d7..b4d7960 100644 --- a/desktop/src/components/SettingsPanel.test.tsx +++ b/desktop/src/components/SettingsPanel.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SettingsPanel } from '@/components/SettingsPanel'; import { createMockSettings } from '@/__tests__'; @@ -10,11 +10,40 @@ vi.mock('@tauri-apps/api/core', () => ({ })); vi.mock('@tauri-apps/plugin-dialog', () => ({ - open: vi.fn(), + open: vi.fn().mockResolvedValue('/trusted/widget'), })); vi.mock('@/services/widgetLoader', () => ({ - installWidgetFromPath: vi.fn(), + installWidgetFromPathWithResult: vi.fn().mockResolvedValue({ + manifest: { + id: 'com.local.clock', + name: 'Local Clock', + description: 'Local widget', + version: '1.0.0', + author: 'Local', + entry: 'index.html', + icon: 'icon.svg', + type: 'community', + category: 'utility', + permissions: ['storage'], + defaultSize: { width: 180, height: 120 }, + }, + }), + installOfficialWidget: vi.fn().mockImplementation(async (id: string) => ({ + manifest: { + id, + name: id === 'com.pinwall.weather' ? '天气小组件' : '时钟小组件', + description: 'Official widget', + version: '1.0.0', + author: 'PinWall Team', + entry: 'index.html', + icon: 'icon.svg', + type: 'official', + category: 'utility', + permissions: id === 'com.pinwall.weather' ? ['storage', 'network'] : ['storage', 'theme'], + defaultSize: { width: 200, height: 160 }, + }, + })), uninstallWidget: vi.fn().mockResolvedValue(true), })); @@ -48,7 +77,7 @@ function installWidget(overrides: Partial = {}) { version: '1.0.0', author: 'Test', entry: 'index.html', - icon: 'icon.png', + icon: 'icon.svg', type: 'community', category: 'utility', permissions: ['storage', 'network', 'system', 'cards', 'ai'], @@ -61,6 +90,7 @@ function installWidget(overrides: Partial = {}) { describe('SettingsPanel', () => { beforeEach(() => { useWidgetStore.setState({ widgets: [], _zIndexCounter: 50 }); + vi.spyOn(window, 'confirm').mockReturnValue(true); }); it('renders section navigation and switches to care reminders', () => { @@ -96,13 +126,53 @@ describe('SettingsPanel', () => { fireEvent.click(screen.getByRole('button', { name: 'Experimental' })); expect(screen.getByText('Risky Widget')).toBeInTheDocument(); - expect(screen.getByText('Network')).toHaveClass('risk'); - expect(screen.getByText('System')).toHaveClass('risk'); - expect(screen.getByText('Cards')).toHaveClass('risk'); + const riskRow = screen.getByText('Risky Widget').closest('.ap-widget-installed-row')!; + expect(within(riskRow as HTMLElement).getByText('Network')).toHaveClass('risk'); + expect(within(riskRow as HTMLElement).getByText('System')).toHaveClass('risk'); + expect(within(riskRow as HTMLElement).getByText('Cards')).toHaveClass('risk'); const aiPermission = screen.getAllByText('AI').find((el) => el.classList.contains('ap-widget-permission')); expect(aiPermission).toHaveClass('risk'); }); + it('shows official widgets with permission explanations', () => { + renderSettings(); + + fireEvent.click(screen.getByRole('button', { name: 'Experimental' })); + + expect(screen.getByText('Official Widget Hub')).toBeInTheDocument(); + expect(screen.getByText('天气小组件')).toBeInTheDocument(); + expect(screen.getAllByText('Network')[0]).toHaveClass('risk'); + expect(screen.getByText(/Accesses external network resources/)).toBeInTheDocument(); + }); + + it('installs official high-risk widgets after confirmation', async () => { + const service = await import('@/services/widgetLoader'); + renderSettings(); + + fireEvent.click(screen.getByRole('button', { name: 'Experimental' })); + const weatherCard = screen.getByText('天气小组件').closest('.ap-widget-catalog-card')!; + fireEvent.click(within(weatherCard as HTMLElement).getByRole('button', { name: 'Install' })); + + await waitFor(() => { + expect(window.confirm).toHaveBeenCalled(); + expect(service.installOfficialWidget).toHaveBeenCalledWith('com.pinwall.weather'); + expect(useWidgetStore.getState().widgets.some((w) => w.manifest.id === 'com.pinwall.weather')).toBe(true); + }); + }); + + it('keeps local widget install as an advanced trusted-source action', async () => { + const service = await import('@/services/widgetLoader'); + renderSettings(); + + fireEvent.click(screen.getByRole('button', { name: 'Experimental' })); + fireEvent.click(screen.getByRole('button', { name: /\+ Choose Folder/ })); + + await waitFor(() => { + expect(service.installWidgetFromPathWithResult).toHaveBeenCalledWith('/trusted/widget'); + expect(screen.getByText('Local Clock installed')).toBeInTheDocument(); + }); + }); + it('can auto-detect weather city from care settings', async () => { const props = renderSettings({ weatherCity: '' }); diff --git a/desktop/src/components/SettingsPanel.tsx b/desktop/src/components/SettingsPanel.tsx index 7b93dc2..98c19a0 100644 --- a/desktop/src/components/SettingsPanel.tsx +++ b/desktop/src/components/SettingsPanel.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; import { useI18n } from "../i18n"; -import type { Settings, AIConfig, QuotaMonitorConfig, QuotaMonitorModel, CareTone, WidgetPermission } from "../types"; +import type { Settings, AIConfig, QuotaMonitorConfig, QuotaMonitorModel, CareTone, WidgetManifest, WidgetPermission } from "../types"; import { AUTO_CHANGE_INTERVALS, DEFAULT_GLOBAL_SHORTCUT, @@ -12,8 +12,16 @@ import { } from "../types"; import { ShortcutRecorder } from "./ShortcutRecorder"; import { useWidgetStore } from "../stores/widgetStore"; -import { installWidgetFromPath, uninstallWidget } from "../services/widgetLoader"; +import { installOfficialWidget, installWidgetFromPathWithResult, uninstallWidget } from "../services/widgetLoader"; import { detectCurrentCity } from "../services/weatherService"; +import { OFFICIAL_WIDGETS } from "../data/officialWidgets"; +import { + getWidgetPermissionDescription, + getWidgetPermissionLabel, + getWidgetPermissionRiskLabel, + hasHighRiskWidgetPermissions, + WIDGET_PERMISSION_RISK, +} from "../data/widgetPermissions"; const PROVIDER_PRESETS = [ { @@ -700,55 +708,58 @@ function TextField({ ); } -const HIGH_RISK_WIDGET_PERMISSIONS = new Set([ - "cards", - "ai", - "system", - "network", -]); - -function getWidgetPermissionLabel(permission: WidgetPermission, lang: "zh" | "en"): string { - const zh: Record = { - storage: "本地存储", - theme: "主题", - notify: "通知", - cards: "便签", - events: "事件", - app: "应用信息", - ai: "AI", - system: "系统状态", - network: "网络", - i18n: "语言", - }; - const en: Record = { - storage: "Storage", - theme: "Theme", - notify: "Notify", - cards: "Cards", - events: "Events", - app: "App Info", - ai: "AI", - system: "System", - network: "Network", - i18n: "Language", - }; - return (lang === "zh" ? zh : en)[permission]; -} - function WidgetExtensionsSection() { const { widgets, toggleWidget } = useWidgetStore(); const lang = useI18n().lang; + const [installingId, setInstallingId] = useState(null); + const [status, setStatus] = useState(""); + + const installedIds = useMemo(() => new Set(widgets.map((w) => w.manifest.id)), [widgets]); + + const setWidgetStatus = (message: string) => { + setStatus(message); + }; + + const finishInstall = (manifest: WidgetManifest | null, error?: string) => { + if (manifest) { + useWidgetStore.getState().installWidget(manifest); + setWidgetStatus(lang === "zh" ? `${manifest.name} 已安装` : `${manifest.name} installed`); + return; + } + setWidgetStatus(error || (lang === "zh" ? "安装失败" : "Install failed")); + }; + + const handleOfficialInstall = async (manifest: WidgetManifest) => { + if (hasHighRiskWidgetPermissions(manifest.permissions)) { + const highRisk = manifest.permissions + .filter((permission) => WIDGET_PERMISSION_RISK[permission] === "high") + .map((permission) => getWidgetPermissionLabel(permission, lang)) + .join(lang === "zh" ? "、" : ", "); + const accepted = window.confirm( + lang === "zh" + ? `${manifest.name} 需要高风险权限:${highRisk}。确认安装?` + : `${manifest.name} requests high-risk permissions: ${highRisk}. Install it?` + ); + if (!accepted) { + setWidgetStatus(lang === "zh" ? "已取消安装" : "Install cancelled"); + return; + } + } + + setInstallingId(manifest.id); + const result = await installOfficialWidget(manifest.id); + finishInstall(result.manifest, result.error); + setInstallingId(null); + }; const handleInstall = async () => { const selected = await open({ directory: true, - title: lang === "zh" ? "选择 Widget 目录" : "Select Widget Directory", + title: lang === "zh" ? "选择可信小组件目录" : "Select Trusted Widget Directory", }); if (selected) { - const manifest = await installWidgetFromPath(selected as string); - if (manifest) { - useWidgetStore.getState().installWidget(manifest); - } + const result = await installWidgetFromPathWithResult(selected as string); + finishInstall(result.manifest, result.error); } }; @@ -756,80 +767,203 @@ function WidgetExtensionsSection() { const ok = await uninstallWidget(id); if (ok) { useWidgetStore.getState().uninstallWidget(id); + setWidgetStatus(lang === "zh" ? "小组件已移除" : "Widget removed"); + } else { + setWidgetStatus(lang === "zh" ? "移除失败" : "Remove failed"); } }; return (
-
{lang === "zh" ? "小组件扩展" : "Widget Extensions"}
+
{lang === "zh" ? "官方小组件中心" : "Official Widget Hub"}
- {lang === "zh" ? "仅安装可信来源的小组件" : "Install widgets only from trusted sources"} + {lang === "zh" ? "先从官方小组件开始" : "Start with official widgets"}
{lang === "zh" - ? "本地小组件运行在桌面宿主中;网络、系统、便签和 AI 权限会访问更敏感的能力。" - : "Local widgets run inside the desktop host. Network, system, cards, and AI permissions can access more sensitive capabilities."} + ? "官方小组件会显示请求的权限;网络、系统、便签和 AI 权限安装前需要确认。" + : "Official widgets show requested permissions. Network, system, cards, and AI permissions require confirmation before install."}
+ +
+ {OFFICIAL_WIDGETS.map((item) => ( + handleOfficialInstall(item.manifest)} + /> + ))} +
+ +
+
+ {lang === "zh" ? "已安装小组件" : "Installed Widgets"} +
{widgets.length === 0 ? (
{lang === "zh" ? "尚未安装任何小组件" : "No widgets installed"}
) : ( widgets.map((w) => ( -
-
-
-
- {w.manifest.name} - - v{w.manifest.version} - - {w.manifest.type === "official" && ( - - {lang === "zh" ? "官方" : "Official"} - - )} -
-
{w.manifest.description}
-
- {w.manifest.permissions.map((permission) => { - const isRisky = HIGH_RISK_WIDGET_PERMISSIONS.has(permission); - return ( - - {getWidgetPermissionLabel(permission, lang)} - - ); - })} -
-
-
- - -
-
+
+ toggleWidget(w.manifest.id, enabled)} + onUninstall={() => handleUninstall(w.manifest.id)} + /> {w !== widgets[widgets.length - 1] &&
}
)) )} +
-
- - - {lang === "zh" ? "市场实验中" : "Marketplace experimental"} +
+ {status &&
{status}
} +
+
+ ); +} + +function OfficialWidgetCard({ + manifest, + installed, + installing, + lang, + onInstall, +}: { + manifest: WidgetManifest; + installed: boolean; + installing: boolean; + lang: "zh" | "en"; + onInstall: () => void; +}) { + return ( +
+
+
+ {manifest.name} + {lang === "zh" ? "官方" : "Official"} +
+
{manifest.description}
+
+ {manifest.category} · v{manifest.version} · {manifest.defaultSize.width}x{manifest.defaultSize.height} +
+ + +
+ +
+ ); +} + +function InstalledWidgetRow({ + manifest, + enabled, + lang, + onToggle, + onUninstall, +}: { + manifest: WidgetManifest; + enabled: boolean; + lang: "zh" | "en"; + onToggle: (enabled: boolean) => void; + onUninstall: () => void; +}) { + return ( +
+
+
+ {manifest.name} + v{manifest.version} + + {manifest.type === "official" ? (lang === "zh" ? "官方" : "Official") : (lang === "zh" ? "本地" : "Local")}
+
{manifest.description}
+
+ {manifest.category} · {manifest.defaultSize.width}x{manifest.defaultSize.height} +
+ +
+
+ +
); } + +function WidgetPermissionChips({ + permissions, + lang, +}: { + permissions: WidgetPermission[]; + lang: "zh" | "en"; +}) { + return ( +
+ {permissions.map((permission) => { + const risk = WIDGET_PERMISSION_RISK[permission]; + return ( + + {getWidgetPermissionLabel(permission, lang)} + + ); + })} +
+ ); +} + +function WidgetPermissionDescriptions({ + permissions, + lang, +}: { + permissions: WidgetPermission[]; + lang: "zh" | "en"; +}) { + return ( +
+ {permissions.map((permission) => { + const risk = WIDGET_PERMISSION_RISK[permission]; + return ( +
+ {getWidgetPermissionLabel(permission, lang)} + {getWidgetPermissionRiskLabel(risk, lang)} · {getWidgetPermissionDescription(permission, lang)} +
+ ); + })} +
+ ); +} diff --git a/desktop/src/components/WidgetFrame.test.tsx b/desktop/src/components/WidgetFrame.test.tsx new file mode 100644 index 0000000..d1cdc7b --- /dev/null +++ b/desktop/src/components/WidgetFrame.test.tsx @@ -0,0 +1,71 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { WidgetFrame } from '@/components/WidgetFrame'; +import { useWidgetStore } from '@/stores/widgetStore'; +import type { WidgetInstance } from '@/types'; + +vi.mock('@/services/widgetLoader', () => ({ + getWidgetFrameUrl: vi.fn(() => 'asset://localhost/widgets/com.pinwall.clock/index.html'), + loadWidgetEntryHtml: vi.fn(async () => 'Clock'), +})); + +vi.mock('@/services/widgetBridge', () => ({ + handleBridgeRequest: vi.fn(), + subscribeWidgetEvent: vi.fn(() => vi.fn()), +})); + +function createInstance(overrides: Partial = {}): WidgetInstance { + return { + manifest: { + id: 'com.pinwall.clock', + name: 'Clock', + description: 'Clock widget', + version: '1.0.0', + author: 'PinWall', + entry: 'index.html', + icon: 'icon.svg', + type: 'official', + category: 'utility', + permissions: ['storage', 'theme', 'i18n'], + defaultSize: { width: 200, height: 200 }, + }, + enabled: true, + x: 50, + y: 60, + size: { width: 200, height: 200 }, + zIndex: 51, + settings: {}, + installedAt: 1, + updatedAt: 1, + ...overrides, + }; +} + +describe('WidgetFrame', () => { + beforeEach(() => { + useWidgetStore.setState({ widgets: [], _zIndexCounter: 50 }); + }); + + it('renders as a docked right-panel widget without absolute positioning', () => { + render(); + + const frame = screen.getByTitle('Clock').closest('.widget-frame') as HTMLElement; + expect(frame).toHaveClass('widget-frame-side'); + expect(frame.style.position).toBe('relative'); + expect(frame.style.left).toBe(''); + expect(frame.style.width).toBe('100%'); + }); + + it('loads installed widgets into iframe srcDoc', async () => { + render(); + + await waitFor(() => + expect(screen.getByTitle('Clock').getAttribute('srcdoc')).toContain('Clock') + ); + }); +}); diff --git a/desktop/src/components/WidgetFrame.tsx b/desktop/src/components/WidgetFrame.tsx index 38953d3..081d971 100644 --- a/desktop/src/components/WidgetFrame.tsx +++ b/desktop/src/components/WidgetFrame.tsx @@ -5,27 +5,60 @@ import type { WidgetSizePreset, } from "../types"; import { WIDGET_SIZE_PRESETS } from "../types"; -import { getWidgetAssetUrl } from "../services/widgetLoader"; +import { getWidgetFrameUrl, loadWidgetEntryHtml } from "../services/widgetLoader"; import { handleBridgeRequest, subscribeWidgetEvent } from "../services/widgetBridge"; import { useWidgetStore } from "../stores/widgetStore"; interface WidgetFrameProps { instance: WidgetInstance; + variant?: "freeform" | "side-panel"; } -export function WidgetFrame({ instance }: WidgetFrameProps) { +export function WidgetFrame({ instance, variant = "freeform" }: WidgetFrameProps) { const { manifest, x, y, size, zIndex, settings, enabled } = instance; const iframeRef = useRef(null); const containerRef = useRef(null); const [isDragging, setIsDragging] = useState(false); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [showSettings, setShowSettings] = useState(false); + const [entryHtml, setEntryHtml] = useState(null); + const [loadError, setLoadError] = useState(null); + const [reloadKey, setReloadKey] = useState(0); const dragOffsetRef = useRef({ x: 0, y: 0 }); const { setPosition, bringToFront, setSize, uninstallWidget, updateSettings } = useWidgetStore(); - const iframeSrc = getWidgetAssetUrl(manifest.id, manifest.entry); - const targetOrigin = new URL(iframeSrc).origin; + const iframeSrc = getWidgetFrameUrl(manifest); + const targetOrigin = entryHtml ? "*" : new URL(iframeSrc).origin; + + useEffect(() => { + let cancelled = false; + + setLoadError(null); + if (!manifest.installedPath && manifest.type !== "official") { + setEntryHtml(null); + return () => { + cancelled = true; + }; + } + + loadWidgetEntryHtml(manifest.id) + .then((html) => { + if (cancelled) return; + setEntryHtml(html); + setLoadError(null); + }) + .catch((err) => { + if (cancelled) return; + console.error("[WidgetFrame] Failed to load widget entry:", err); + setEntryHtml(null); + setLoadError(err instanceof Error ? err.message : String(err)); + }); + + return () => { + cancelled = true; + }; + }, [manifest.id, manifest.entry, manifest.installedPath, reloadKey]); // ── postMessage 桥接 ── useEffect(() => { @@ -47,7 +80,7 @@ export function WidgetFrame({ instance }: WidgetFrameProps) { window.addEventListener("message", handler); return () => window.removeEventListener("message", handler); - }, [manifest, targetOrigin]); + }, [manifest, targetOrigin, reloadKey]); // ── 事件推送:向 iframe 广播应用事件 ── useEffect(() => { @@ -61,15 +94,14 @@ export function WidgetFrame({ instance }: WidgetFrameProps) { ); }); return unsub; - }, [manifest.permissions, targetOrigin]); + }, [manifest.permissions, targetOrigin, reloadKey]); // ── Widget 初始化:发送配置 ── useEffect(() => { const iframe = iframeRef.current; if (!iframe) return; - const onLoad = () => { - // 发送初始化配置 + const sendReady = () => { iframe.contentWindow?.postMessage( { type: "event", @@ -84,13 +116,23 @@ export function WidgetFrame({ instance }: WidgetFrameProps) { ); }; + const onLoad = () => { + sendReady(); + window.setTimeout(sendReady, 120); + }; + iframe.addEventListener("load", onLoad); - return () => iframe.removeEventListener("load", onLoad); - }, [manifest.id, settings, targetOrigin]); + const readyTimer = window.setTimeout(sendReady, 120); + return () => { + window.clearTimeout(readyTimer); + iframe.removeEventListener("load", onLoad); + }; + }, [manifest.id, settings, targetOrigin, reloadKey, entryHtml]); // ── 拖拽 ── const handlePointerDown = useCallback( (e: React.PointerEvent) => { + if (variant === "side-panel") return; if ((e.target as HTMLElement).closest(".widget-control")) { return; } @@ -114,7 +156,7 @@ export function WidgetFrame({ instance }: WidgetFrameProps) { document.addEventListener("pointermove", handlePointerMove); document.addEventListener("pointerup", handlePointerUp); }, - [manifest.id, x, y, setPosition, bringToFront] + [manifest.id, x, y, setPosition, bringToFront, variant] ); // ── 右键菜单 ── @@ -150,20 +192,26 @@ export function WidgetFrame({ instance }: WidgetFrameProps) { // ── 刷新 ── const handleRefresh = useCallback(() => { const iframe = iframeRef.current; - if (iframe) { + if (entryHtml) { + setReloadKey((key) => key + 1); + } else if (iframe) { iframe.src = iframe.src; // 重新加载 } setContextMenu(null); - }, []); + }, [entryHtml]); if (!enabled) return null; - return ( -
@@ -181,18 +236,21 @@ export function WidgetFrame({ instance }: WidgetFrameProps) { style={{ width: "100%", height: "100%", - borderRadius: 16, + position: "relative", + borderRadius: 14, overflow: "hidden", - background: "rgba(255, 255, 255, 0.85)", - backdropFilter: "blur(20px)", - WebkitBackdropFilter: "blur(20px)", - boxShadow: "0 4px 24px rgba(0,0,0,0.08), 0 1px 4px rgba(0,0,0,0.04)", - border: "1px solid rgba(255,255,255,0.5)", + background: "rgba(28, 28, 30, 0.76)", + backdropFilter: "blur(32px) saturate(170%)", + WebkitBackdropFilter: "blur(32px) saturate(170%)", + boxShadow: "0 8px 28px rgba(0,0,0,0.22)", + border: "1px solid rgba(255,255,255,0.16)", }} >