Skip to content

Commit 69b9135

Browse files
committed
feat(desktop): add Craft system tray
1 parent 07f68ec commit 69b9135

4 files changed

Lines changed: 470 additions & 6 deletions

File tree

Lines changed: 218 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,220 @@
1-
import { runCommand } from '@stacksjs/cli'
2-
import { frameworkPath } from '@stacksjs/path'
1+
import { homedir, networkInterfaces } from 'node:os'
2+
import { basename, join } from 'node:path'
3+
import process from 'node:process'
4+
import { bold, cyan, dim, green } from '@stacksjs/cli'
5+
import { openDevWindow } from '@stacksjs/desktop'
6+
import { projectPath, storagePath } from '@stacksjs/path'
7+
import { findStacksProjects } from '@stacksjs/utils'
8+
import { findAvailablePort, waitForServer } from './dashboard-utils'
39

4-
await runCommand('bun run dev', {
5-
cwd: frameworkPath('system-tray'),
10+
interface TrayActionRequest {
11+
action?: string
12+
project?: string
13+
confirmed?: boolean
14+
}
15+
16+
const preferredPort = Number(process.env.PORT_SYSTEM_TRAY) || 3009
17+
const port = await findAvailablePort(preferredPort)
18+
const trayViews = storagePath('framework/defaults/views/system-tray')
19+
const appUrl = process.env.APP_URL || 'stacks.test'
20+
const domain = appUrl.replace(/^https?:\/\//, '').replace(/\/$/, '')
21+
const hasPrettyDomain = !domain.includes('localhost:') && domain !== 'localhost'
22+
const trayDomain = hasPrettyDomain ? `tray.${domain}` : null
23+
const localUrl = `http://localhost:${port}`
24+
25+
let cachedProjects: string[] | undefined
26+
async function discoverProjects(refresh = false): Promise<string[]> {
27+
if (cachedProjects && !refresh)
28+
return cachedProjects
29+
30+
const discovered = await findStacksProjects(join(homedir(), 'Code'), { quiet: true }).catch(() => [])
31+
const candidates = [...new Set([projectPath(), ...discovered])]
32+
const checks = await Promise.all(candidates.map(async project => ({
33+
project,
34+
valid: await Bun.file(join(project, 'buddy')).exists(),
35+
})))
36+
cachedProjects = checks
37+
.filter(check => check.valid)
38+
.map(check => check.project)
39+
.sort((a, b) => basename(a).localeCompare(basename(b)))
40+
return cachedProjects
41+
}
42+
43+
async function runBuddy(project: string, args: string[]): Promise<{ output: string, exitCode: number }> {
44+
const child = Bun.spawn([join(project, 'buddy'), ...args, '--no-interaction'], {
45+
cwd: project,
46+
stdout: 'pipe',
47+
stderr: 'pipe',
48+
})
49+
const [stdout, stderr, exitCode] = await Promise.all([
50+
new Response(child.stdout).text(),
51+
new Response(child.stderr).text(),
52+
child.exited,
53+
])
54+
return { output: [stdout, stderr].filter(Boolean).join('\n').trim(), exitCode }
55+
}
56+
57+
function openPath(path: string): void {
58+
const command = process.platform === 'darwin'
59+
? ['open', path]
60+
: process.platform === 'win32'
61+
? ['cmd', '/c', 'start', '', path]
62+
: ['xdg-open', path]
63+
Bun.spawn(command, { stdout: 'ignore', stderr: 'ignore' }).unref()
64+
}
65+
66+
function openTerminal(project: string): void {
67+
const command = process.platform === 'darwin'
68+
? ['open', '-a', 'Terminal', project]
69+
: process.platform === 'win32'
70+
? ['cmd', '/c', 'start', 'cmd', '/K', `cd /d ${project}`]
71+
: ['x-terminal-emulator', '--working-directory', project]
72+
Bun.spawn(command, { stdout: 'ignore', stderr: 'ignore' }).unref()
73+
}
74+
75+
function localIpAddress(): string {
76+
for (const addresses of Object.values(networkInterfaces())) {
77+
for (const address of addresses || []) {
78+
if (address.family === 'IPv4' && !address.internal)
79+
return address.address
80+
}
81+
}
82+
return '127.0.0.1'
83+
}
84+
85+
function dashboardUrl(path = ''): string {
86+
const base = hasPrettyDomain ? `https://dashboard.${domain}` : `http://localhost:${Number(process.env.PORT_ADMIN) || 3002}`
87+
return `${base}${path}`
88+
}
89+
90+
async function handleAction(input: TrayActionRequest): Promise<Response> {
91+
const projects = await discoverProjects()
92+
const project = input.project || projectPath()
93+
if (!projects.includes(project))
94+
return Response.json({ ok: false, error: 'Unknown Stacks project' }, { status: 400 })
95+
96+
switch (input.action) {
97+
case 'refresh':
98+
return Response.json({ ok: true, projects: await discoverProjects(true) })
99+
case 'open-terminal':
100+
openTerminal(project)
101+
return Response.json({ ok: true, message: `Opened Terminal in ${basename(project)}` })
102+
case 'env-check': {
103+
const result = await runBuddy(project, ['doctor'])
104+
return Response.json({ ok: result.exitCode === 0, ...result })
105+
}
106+
case 'settings':
107+
openPath(dashboardUrl('/settings'))
108+
return Response.json({ ok: true, message: 'Opened dashboard settings' })
109+
case 'check-updates': {
110+
const result = await runBuddy(project, ['outdated'])
111+
return Response.json({ ok: result.exitCode === 0, ...result })
112+
}
113+
case 'copy-ip':
114+
return Response.json({ ok: true, value: localIpAddress(), message: 'IP address ready to copy' })
115+
case 'open-dashboard':
116+
openPath(dashboardUrl())
117+
return Response.json({ ok: true, message: 'Opened dashboard' })
118+
case 'buddy-commands': {
119+
const result = await runBuddy(project, ['list'])
120+
return Response.json({ ok: result.exitCode === 0, ...result })
121+
}
122+
case 'deploy': {
123+
if (!input.confirmed)
124+
return Response.json({ ok: false, confirmationRequired: true, error: 'Deployment confirmation required' }, { status: 409 })
125+
const child = Bun.spawn([join(project, 'buddy'), 'deploy', '--no-interaction'], {
126+
cwd: project,
127+
stdout: 'inherit',
128+
stderr: 'inherit',
129+
})
130+
child.unref()
131+
return Response.json({ ok: true, message: `Deployment started for ${basename(project)}` })
132+
}
133+
case 'deploy-logs':
134+
openPath(join(project, 'storage/logs'))
135+
return Response.json({ ok: true, message: 'Opened deployment logs' })
136+
case 'site-logs':
137+
openPath(join(project, 'storage/logs'))
138+
return Response.json({ ok: true, message: 'Opened site logs' })
139+
case 'error-logs':
140+
openPath(join(project, 'storage/logs'))
141+
return Response.json({ ok: true, message: 'Opened error logs' })
142+
case 'edit-env':
143+
openPath(join(project, '.env'))
144+
return Response.json({ ok: true, message: 'Opened .env' })
145+
case 'edit-dns':
146+
openPath(join(project, 'config/dns.ts'))
147+
return Response.json({ ok: true, message: 'Opened DNS configuration' })
148+
case 'edit-email':
149+
openPath(join(project, 'config/email.ts'))
150+
return Response.json({ ok: true, message: 'Opened email configuration' })
151+
case 'ask-buddy':
152+
openPath(dashboardUrl('/buddy'))
153+
return Response.json({ ok: true, message: 'Opened Ask Buddy' })
154+
default:
155+
return Response.json({ ok: false, error: 'Unknown tray action' }, { status: 400 })
156+
}
157+
}
158+
159+
const { serve } = await import('bun-plugin-stx/serve')
160+
const serverPromise = serve({
161+
patterns: [trayViews],
162+
port,
163+
componentsDir: storagePath('framework/defaults/resources/components'),
164+
quiet: true,
165+
auth: false,
166+
routes: {
167+
'/api/tray/projects': async () => Response.json({ ok: true, projects: await discoverProjects() }),
168+
'/api/tray/action': async (request: Request) => {
169+
if (request.method !== 'POST')
170+
return Response.json({ ok: false, error: 'Method not allowed' }, { status: 405 })
171+
return handleAction(await request.json() as TrayActionRequest)
172+
},
173+
},
174+
} as any)
175+
serverPromise.catch((error: Error) => {
176+
console.error(`System tray server failed: ${error.message}`)
177+
process.exit(1)
178+
})
179+
180+
let proxyStarted = false
181+
if (trayDomain && !process.env.STACKS_PROXY_MANAGED) {
182+
try {
183+
const { startProxies } = await import('@stacksjs/rpx')
184+
await startProxies({
185+
proxies: [{ from: `localhost:${port}`, to: trayDomain, cleanUrls: false }],
186+
https: { basePath: `${process.env.HOME}/.stacks/ssl`, validityDays: 825 },
187+
regenerateUntrustedCerts: false,
188+
})
189+
proxyStarted = true
190+
}
191+
catch {
192+
proxyStarted = false
193+
}
194+
}
195+
196+
await waitForServer(port, 2_000)
197+
const url = trayDomain && proxyStarted ? `https://${trayDomain}` : localUrl
198+
console.log()
199+
console.log(` ${bold(cyan('stacks tray'))}`)
200+
console.log()
201+
console.log(` ${green('➜')} ${bold('Local')}: ${cyan(url)}`)
202+
if (trayDomain) console.log(` ${dim('➜')} ${dim('Origin')}: ${dim(localUrl)}`)
203+
console.log()
204+
205+
await openDevWindow(port, {
206+
url,
207+
title: 'Stacks',
208+
width: 440,
209+
height: 680,
210+
systemTray: true,
211+
hideDockIcon: true,
212+
hotReload: true,
6213
})
214+
215+
const stop = () => {
216+
process.exit(0)
217+
}
218+
process.on('SIGINT', stop)
219+
process.on('SIGTERM', stop)
220+
await new Promise(() => {})
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { buildTrayMenu } from '../../../defaults/resources/functions/system-tray'
3+
4+
describe('system tray menu', () => {
5+
test('includes every global shortcut', () => {
6+
const menu = buildTrayMenu(['/tmp/example'])
7+
const actions = menu.flatMap(item => item.action || [])
8+
expect(actions).toContain('refresh')
9+
expect(actions).toContain('open-terminal')
10+
expect(actions).toContain('env-check')
11+
expect(actions).toContain('settings')
12+
expect(actions).toContain('check-updates')
13+
})
14+
15+
test('builds the complete project submenu', () => {
16+
const projects = buildTrayMenu(['/Users/example/Code/shop'])
17+
const projectMenu = projects.find(item => item.label === 'Projects')
18+
const shop = projectMenu?.submenu?.[0]
19+
expect(shop?.label).toBe('shop')
20+
const actions = shop?.submenu?.map(item => item.action).filter(Boolean)
21+
expect(actions).toContain('open-dashboard::/Users/example/Code/shop')
22+
expect(actions).toContain('deploy::/Users/example/Code/shop')
23+
expect(actions).toContain('ask-buddy::/Users/example/Code/shop')
24+
})
25+
})
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
interface TrayMenuItem {
2+
label?: string
3+
type?: 'normal' | 'separator' | 'submenu'
4+
action?: string
5+
submenu?: TrayMenuItem[]
6+
}
7+
8+
interface CraftTrayBridge {
9+
setTitle(title: string): Promise<void>
10+
setTooltip(tooltip: string): Promise<void>
11+
setMenu(items: TrayMenuItem[]): Promise<void>
12+
onClickToggleWindow(): () => void
13+
}
14+
15+
interface CraftBridge {
16+
tray?: CraftTrayBridge
17+
}
18+
19+
type TrayActionHandler = (action: string) => void | Promise<void>
20+
21+
function bridge(): CraftBridge | undefined {
22+
return (globalThis as typeof globalThis & { craft?: CraftBridge }).craft
23+
}
24+
25+
export function buildTrayMenu(projects: string[]): TrayMenuItem[] {
26+
const projectItems = projects.map((project) => {
27+
const name = project.split('/').filter(Boolean).pop() || project
28+
return {
29+
label: name,
30+
type: 'submenu' as const,
31+
submenu: [
32+
{ label: 'Open Terminal', action: `open-terminal::${project}` },
33+
{ label: 'Copy IP Address', action: `copy-ip::${project}` },
34+
{ label: 'Open Dashboard', action: `open-dashboard::${project}` },
35+
{ label: 'Buddy Commands', action: `buddy-commands::${project}` },
36+
{ label: 'Deploy', action: `deploy::${project}` },
37+
{ type: 'separator' as const },
38+
{ label: 'Deploy Logs', action: `deploy-logs::${project}` },
39+
{ label: 'Site Logs', action: `site-logs::${project}` },
40+
{ label: 'Error Logs', action: `error-logs::${project}` },
41+
{ type: 'separator' as const },
42+
{ label: 'Edit .env', action: `edit-env::${project}` },
43+
{ label: 'Edit DNS', action: `edit-dns::${project}` },
44+
{ label: 'Edit Email Addresses', action: `edit-email::${project}` },
45+
{ label: 'Ask Buddy', action: `ask-buddy::${project}` },
46+
],
47+
}
48+
})
49+
50+
return [
51+
{ label: 'Refresh', action: 'refresh' },
52+
{ label: 'Open Terminal', action: 'open-terminal' },
53+
{ label: 'Environment Check', action: 'env-check' },
54+
{ label: 'Settings', action: 'settings' },
55+
{ label: 'Check for Updates', action: 'check-updates' },
56+
{ type: 'separator' },
57+
{ label: 'Projects', type: 'submenu', submenu: projectItems },
58+
{ type: 'separator' },
59+
{ label: 'Quit', action: 'quit' },
60+
]
61+
}
62+
63+
export async function installTrayMenu(projects: string[], onAction: TrayActionHandler): Promise<() => void> {
64+
const craft = bridge()
65+
if (!craft?.tray)
66+
return () => {}
67+
68+
await craft.tray.setTitle('Stacks')
69+
await craft.tray.setTooltip('Stacks project shortcuts')
70+
await craft.tray.setMenu(buildTrayMenu(projects))
71+
const removeClick = craft.tray.onClickToggleWindow()
72+
const listener = (event: Event) => {
73+
const action = (event as CustomEvent<{ action?: string }>).detail?.action
74+
if (action && action !== 'quit') void onAction(action)
75+
}
76+
globalThis.addEventListener('craft:tray:menuAction', listener)
77+
return () => {
78+
removeClick()
79+
globalThis.removeEventListener('craft:tray:menuAction', listener)
80+
}
81+
}

0 commit comments

Comments
 (0)