diff --git a/crates/goose-server/src/routes/goose_apps.rs b/crates/goose-server/src/routes/goose_apps.rs index 3836adc80d..ce0ace6070 100644 --- a/crates/goose-server/src/routes/goose_apps.rs +++ b/crates/goose-server/src/routes/goose_apps.rs @@ -116,56 +116,6 @@ async fn get_app( } } -const CLOCK_PRD: &str = r#" -# Digital Clock Widget - -## Overview -A simple clock widget that displays the current time and date. - -## Core Functionality - -### Time Display -- Shows current time updated every second -- Supports both 12-hour (with AM/PM) and 24-hour format -- Uses monospace font for consistent digit width and easy readability - -### Date Display -- Shows full date including day of week, month, day, and year -- Displays below the time in a smaller, secondary style - -### Settings -- User can toggle between 12-hour and 24-hour time format -- Format preference persists across sessions -- Default: 24-hour format - -## Visual Design - -### Layout -- Vertically stacked: time on top, date below -- Content centered within widget bounds -- Clear visual hierarchy (time prominent, date secondary) - -### Typography -- Time: Large, bold, monospace -- Date: Medium size, lighter color -- Settings toggle: Small, subtle, changes on hover - -### Interaction -- Clickable settings control to toggle time format -- Visual feedback on hover for interactive elements - -## Default Dimensions -- Width: 300px -- Height: 180px -- Resizable by user - -## Technical Requirements -- Updates automatically every second -- Minimal resource usage -- Properly cleans up when widget is closed -- Uses system locale for date formatting -"#; - #[utoipa::path( post, path = "/apps", @@ -188,21 +138,12 @@ async fn create_app( ) -> Result<(StatusCode, Json), ErrorResponse> { let manager = GooseAppsManager::new()?; - let app = if request.app.name == "" { - let clock_js = GOOSE_APP_ASSETS - .get_file("clock.js") - .ok_or_else(|| ErrorResponse::internal("clock.js not found"))? - .contents_utf8() - .ok_or_else(|| ErrorResponse::internal("clock.js is not valid UTF-8"))?; - GooseApp { - name: "Clock".to_string(), - description: Some("Example Clock app".to_string()), - width: Some(300), - height: Some(300), - resizable: Some(false), - js_implementation: clock_js.to_string(), - prd: CLOCK_PRD.parse().unwrap(), - } + let app = if request.app.name.is_empty() { + let clock_gapp = GOOSE_APP_ASSETS + .get_file("Clock.gapp") + .and_then(|f| f.contents_utf8()) + .ok_or_else(|| ErrorResponse::internal("invalid or missing Clock.gapp"))?; + GooseApp::from_file_content(clock_gapp)? } else { request.app }; diff --git a/crates/goose/src/goose_apps/app.rs b/crates/goose/src/goose_apps/app.rs index 37f99f1073..49e5ff2d47 100644 --- a/crates/goose/src/goose_apps/app.rs +++ b/crates/goose/src/goose_apps/app.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{Error, Result}; use serde::{Deserialize, Serialize}; use std::fs; use std::path::Path; @@ -24,6 +24,10 @@ impl GooseApp { pub fn from_file>(path: P) -> Result { let content = fs::read_to_string(path)?; + Self::from_file_content(&content) + } + + pub fn from_file_content(content: &str) -> Result { let parts: Vec<&str> = content.splitn(3, Self::FRONTMATTER_DELIMITER).collect(); if parts.len() != 3 { @@ -42,6 +46,7 @@ impl GooseApp { pub fn to_file_content(&self) -> Result { let mut metadata = self.clone(); metadata.js_implementation = String::new(); + metadata.prd = String::new(); let yaml_content = serde_yaml::to_string(&metadata)?; Ok(format!( "{}{}{}{}{}", diff --git a/ui/desktop/src/goose_apps/assets/Clock.gapp b/ui/desktop/src/goose_apps/assets/Clock.gapp new file mode 100644 index 0000000000..836804c617 --- /dev/null +++ b/ui/desktop/src/goose_apps/assets/Clock.gapp @@ -0,0 +1,223 @@ +name: Clock +description: Swiss Railway Clock widget +width: 300 +height: 300 +resizable: false +--- +class ClockWidget extends GooseWidget { + constructor(api) { + super(api); + this.animationFrame = null; + this.lastSecond = -1; + this.pauseUntil = 0; + } + + css() { + return ` + .clock-container { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + } + + #clockCanvas { + display: block; + } + `; + } + + onMount() { + this.canvas = document.getElementById('clockCanvas'); + this.ctx = this.canvas.getContext('2d'); + this.animate(); + } + + onClose() { + if (this.animationFrame) { + cancelAnimationFrame(this.animationFrame); + } + } + + animate() { + this.drawClock(); + this.animationFrame = requestAnimationFrame(() => this.animate()); + } + + drawClock() { + const now = Date.now(); + const date = new Date(now); + + const canvas = this.canvas; + const ctx = this.ctx; + const radius = canvas.width / 2; + const centerX = radius; + const centerY = radius; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + + ctx.beginPath(); + ctx.arc(centerX, centerY, radius - 5, 0, 2 * Math.PI); + ctx.fillStyle = 'white'; + ctx.fill(); + ctx.strokeStyle = 'black'; + ctx.lineWidth = 2; + ctx.stroke(); + + for (let i = 0; i < 60; i++) { + const angle = (i * 6) * Math.PI / 180; + const isHourMark = i % 5 === 0; + + if (isHourMark) { + const x1 = centerX + Math.sin(angle) * (radius - 15); + const y1 = centerY - Math.cos(angle) * (radius - 15); + const x2 = centerX + Math.sin(angle) * (radius - 40); + const y2 = centerY - Math.cos(angle) * (radius - 40); + + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.strokeStyle = 'black'; + ctx.lineWidth = 8; + ctx.lineCap = 'butt'; + ctx.stroke(); + } else { + const x1 = centerX + Math.sin(angle) * (radius - 15); + const y1 = centerY - Math.cos(angle) * (radius - 15); + const x2 = centerX + Math.sin(angle) * (radius - 25); + const y2 = centerY - Math.cos(angle) * (radius - 25); + + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.strokeStyle = 'black'; + ctx.lineWidth = 2; + ctx.lineCap = 'butt'; + ctx.stroke(); + } + } + + ctx.font = '16px sans-serif'; + ctx.fillStyle = 'black'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('GOOSE CLOCK', centerX, centerY - 65); + + const seconds = date.getSeconds(); + const milliseconds = date.getMilliseconds(); + + if (seconds === 0 && this.lastSecond === 59) { + this.pauseUntil = now + 1500; + } + this.lastSecond = seconds; + + const hours = date.getHours() % 12; + const minutes = date.getMinutes(); + + const hourAngle = ((hours + minutes / 60) * 30) * Math.PI / 180; + this.drawHand(ctx, centerX, centerY, hourAngle, radius * 0.65, 11, 'black'); + + const minuteAngle = ((minutes + seconds / 60) * 6) * Math.PI / 180; + this.drawHand(ctx, centerX, centerY, minuteAngle, radius * 0.90, 8, 'black'); + + let secondAngle; + if (now < this.pauseUntil) { + secondAngle = 0; + } else { + secondAngle = ((seconds + milliseconds / 1000) * 6) * Math.PI / 180; + } + this.drawSecondHand(ctx, centerX, centerY, secondAngle, radius * 0.65); + + ctx.beginPath(); + ctx.arc(centerX, centerY, 6, 0, 2 * Math.PI); + ctx.fillStyle = '#d32f2f'; + ctx.fill(); + } + + drawHand(ctx, x, y, angle, length, width, color) { + ctx.save(); + ctx.translate(x, y); + ctx.rotate(angle); + + ctx.beginPath(); + ctx.rect(-width/2, -length, width, length + 20); + ctx.fillStyle = color; + ctx.fill(); + + ctx.restore(); + } + + drawSecondHand(ctx, x, y, angle, length) { + ctx.save(); + ctx.translate(x, y); + ctx.rotate(angle); + + ctx.beginPath(); + ctx.moveTo(0, length * 0.2); + ctx.lineTo(0, -length); + ctx.strokeStyle = '#d32f2f'; + ctx.lineWidth = 2.5; + ctx.lineCap = 'butt'; + ctx.stroke(); + + ctx.beginPath(); + ctx.arc(0, -length, 10, 0, 2 * Math.PI); + ctx.fillStyle = '#d32f2f'; + ctx.fill(); + + ctx.restore(); + } + + render() { + return ` +
+ +
+ `; + } + + getDefaultSize() { + return { width: 300, height: 300 }; + } +} +--- +# Swiss Railway Clock Widget + +## Overview +An analog clock widget inspired by the iconic Swiss railway clock (Hans Hilfiker design). + +## Core Functionality + +### Time Display +- Analog clock face with hour, minute, and second hands +- Second hand sweeps smoothly in small increments (10 updates/second) +- Distinctive "pause and jump" behavior: second hand pauses at 12 o'clock for ~1.5 seconds before jumping to the next minute +- Hour and minute hands update continuously based on current time +- Clean white face with simple black hour markers + +### Swiss Clock Behavior +- Second hand: smooth sweep with characteristic pause at top +- During pause: hour and minute hands continue to move +- After pause: second hand jumps to correct position and resumes sweep +- Mimics the synchronization behavior of Swiss railway station clocks + +### Visual Design +- Circular white clock face +- Black outline circle +- Simple black tick marks at hour positions +- Red lollipop-style second hand (round tip) +- Black tapered hour and minute hands +- Centered on widget canvas + +## Default Dimensions +- Width: 300px +- Height: 300px +- Not resizable (maintains aspect ratio) + +## Technical Requirements +- Uses HTML5 canvas for rendering +- Updates 10 times per second for smooth animation +- Properly implements Swiss railway clock timing behavior +- Cleans up animation loop when widget closes + diff --git a/ui/desktop/src/goose_apps/index.ts b/ui/desktop/src/goose_apps/index.ts index 40d4274839..1f7745fb24 100644 --- a/ui/desktop/src/goose_apps/index.ts +++ b/ui/desktop/src/goose_apps/index.ts @@ -44,10 +44,13 @@ export function getContainerHtml(gapp: GooseApp): string { } export async function launchGooseApp(gapp: GooseApp): Promise { + const desiredContentWidth = gapp.width || 800; + const desiredContentHeight = gapp.height || 600; + const appWindow = new BrowserWindow({ title: gapp.name, - width: gapp.width || 800, - height: gapp.height || 600, + width: desiredContentWidth, + height: desiredContentHeight, resizable: gapp.resizable ?? true, webPreferences: { nodeIntegration: false, @@ -56,10 +59,16 @@ export async function launchGooseApp(gapp: GooseApp): Promise { }, }); - const html = getContainerHtml(gapp); + const [currentContentWidth, currentContentHeight] = appWindow.getContentSize(); + const [currentWindowWidth, currentWindowHeight] = appWindow.getSize(); + const frameWidth = currentWindowWidth - currentContentWidth; + const frameHeight = currentWindowHeight - currentContentHeight; + + appWindow.setSize(desiredContentWidth + frameWidth, desiredContentHeight + frameHeight); + + const html = getContainerHtml(gapp); await appWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); - appWindow.show(); appWindow.show(); }