Skip to content

Commit 07f68ec

Browse files
committed
feat(desktop): complete Craft runtime
1 parent a1af10b commit 07f68ec

7 files changed

Lines changed: 189 additions & 23 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ storage/framework/cache/
8888
.tmp-upgrade/
8989
.claude/scheduled_tasks.lock
9090
/storage/framework/frontend-dist
91+
/storage/framework/desktop-dist
9192
/.stacks
9293
/.ts-cloud
9394
.claude/settings.local.json
Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,46 @@
1-
import { runCommand } from '@stacksjs/cli'
2-
import { corePath } from '@stacksjs/path'
1+
import { chmodSync, copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
2+
import { basename, join } from 'node:path'
3+
import process from 'node:process'
4+
import { log, runCommand } from '@stacksjs/cli'
5+
import { corePath, projectPath, storagePath } from '@stacksjs/path'
6+
import { resolveCraftBinary } from '@stacksjs/desktop'
37

4-
await runCommand('bun run build:app', {
5-
// ...options,
6-
cwd: corePath('desktop'),
8+
const outputDir = storagePath('framework/desktop-dist')
9+
const launcherName = process.platform === 'win32' ? 'stacks-desktop.exe' : 'stacks-desktop'
10+
const runtimeName = process.platform === 'win32' ? 'craft-runtime.exe' : 'craft-runtime'
11+
const appUrl = process.env.DESKTOP_URL || process.env.APP_URL
12+
13+
if (!appUrl)
14+
throw new Error('Desktop builds require APP_URL or DESKTOP_URL so the native app knows which Stacks application to open')
15+
16+
const url = new URL(/^https?:\/\//.test(appUrl) ? appUrl : `https://${appUrl}`)
17+
const craftBinary = resolveCraftBinary()
18+
if (basename(craftBinary) === 'craft' && !existsSync(craftBinary))
19+
throw new Error('Build Craft first in ~/Code/Tools/craft, or set CRAFT_BIN to the native Craft binary')
20+
21+
if (existsSync(outputDir))
22+
rmSync(outputDir, { recursive: true })
23+
mkdirSync(outputDir, { recursive: true })
24+
25+
await runCommand('bun run build', { cwd: corePath('desktop') })
26+
await runCommand(`bun build --compile ${JSON.stringify(corePath('desktop/src/launcher.ts'))} --outfile ${JSON.stringify(join(outputDir, launcherName))}`, {
27+
cwd: projectPath(),
728
})
29+
30+
copyFileSync(craftBinary, join(outputDir, runtimeName))
31+
if (process.platform !== 'win32') {
32+
chmodSync(join(outputDir, launcherName), 0o755)
33+
chmodSync(join(outputDir, runtimeName), 0o755)
34+
}
35+
36+
writeFileSync(join(outputDir, 'desktop.json'), `${JSON.stringify({
37+
url: url.toString().replace(/\/$/, ''),
38+
title: process.env.APP_NAME || 'Stacks',
39+
width: 1400,
40+
height: 900,
41+
darkMode: false,
42+
systemTray: true,
43+
hideDockIcon: false,
44+
}, null, 2)}\n`)
45+
46+
log.success(`Built Craft desktop application in ${outputDir}`)
Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
import { runCommand } from '@stacksjs/cli'
2-
import { frameworkPath } from '@stacksjs/path'
3-
4-
await runCommand('bun run dev', {
5-
// ...options,
6-
cwd: frameworkPath('views/desktop'),
7-
})
1+
// The Stacks desktop application is the dashboard hosted in a native Craft
2+
// window. Keeping one server entry means desktop and dashboard share routes,
3+
// settings, authentication, rpx/tlsx pretty URLs, and hot reload behavior.
4+
await import('./dashboard')

storage/framework/core/desktop/package.json

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,10 @@
5454
"dist"
5555
],
5656
"scripts": {
57-
"dev": "bunx --bun vite ../../dashboard -c ../vite-config/src/desktop.ts",
58-
"dev:app": "craft dev",
57+
"dev": "bun ../../actions/src/dev/desktop.ts",
58+
"dev:app": "bun ../../actions/src/dev/desktop.ts",
5959
"build": "bun build.ts",
60-
"build:app": "bunx --bun vite build ../../dashboard -c ../vite-config/src/desktop.ts",
61-
"craft:build": "craft build --release",
60+
"build:app": "bun ../../actions/src/build/desktop.ts",
6261
"typecheck": "bun tsc --noEmit",
6362
"prepublishOnly": "bun run build"
6463
},

storage/framework/core/desktop/src/index.ts

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import { existsSync } from 'node:fs'
2+
import { homedir } from 'node:os'
3+
import { join } from 'node:path'
4+
15
export interface Desktop {
26
app: unknown
37
core: unknown
@@ -49,10 +53,30 @@ export interface OpenDevWindowOptions {
4953
sidebarConfig?: unknown
5054
devTools?: boolean
5155
craftBin?: string
56+
systemTray?: boolean
57+
hideDockIcon?: boolean
58+
menubarOnly?: boolean
5259
}
5360

5461
export type CraftLauncher = (command: string[]) => void | Promise<void>
5562

63+
export function resolveCraftBinary(explicit: string | undefined = process.env.CRAFT_BIN): string {
64+
if (explicit) {
65+
if (!existsSync(explicit))
66+
throw new Error(`Craft binary not found: ${explicit}`)
67+
return explicit
68+
}
69+
70+
const craftRoot = join(homedir(), 'Code/Tools/craft')
71+
const candidates = [
72+
join(craftRoot, 'packages/zig/zig-out/bin/craft'),
73+
join(craftRoot, 'craft'),
74+
join(craftRoot, 'bin/craft'),
75+
]
76+
77+
return candidates.find(candidate => existsSync(candidate)) || 'craft'
78+
}
79+
5680
export function resolveDevWindowUrl(port: number, options: OpenDevWindowOptions = {}): string {
5781
if (!Number.isInteger(port) || port < 1 || port > 65_535)
5882
throw new RangeError(`Invalid desktop development port: ${port}`)
@@ -67,7 +91,7 @@ export function resolveDevWindowUrl(port: number, options: OpenDevWindowOptions
6791

6892
export function craftDevCommand(port: number, options: OpenDevWindowOptions = {}): string[] {
6993
const command = [
70-
options.craftBin || process.env.CRAFT_BIN || 'craft',
94+
resolveCraftBinary(options.craftBin),
7195
resolveDevWindowUrl(port, options),
7296
'--title',
7397
options.title || 'Stacks',
@@ -78,12 +102,46 @@ export function craftDevCommand(port: number, options: OpenDevWindowOptions = {}
78102
]
79103

80104
if (options.hotReload !== false) command.push('--hot-reload')
81-
if (options.devTools !== false) command.push('--dev-tools')
82-
if (options.darkMode) command.push('--dark-mode')
105+
if (options.devTools === false) command.push('--no-devtools')
106+
if (options.darkMode) command.push('--dark')
107+
if (options.systemTray) command.push('--system-tray')
108+
if (options.hideDockIcon) command.push('--hide-dock-icon')
109+
if (options.menubarOnly) command.push('--menubar-only')
83110

84111
return command
85112
}
86113

114+
export interface InviteLinkOptions {
115+
email?: string
116+
team?: string
117+
role?: string
118+
expiresAt?: Date | string
119+
}
120+
121+
export function createInviteLink(baseUrl: string, token: string, options: InviteLinkOptions = {}): string {
122+
if (!token.trim())
123+
throw new Error('Invite token is required')
124+
125+
const url = new URL('/invite', /^https?:\/\//.test(baseUrl) ? baseUrl : `https://${baseUrl}`)
126+
url.searchParams.set('token', token)
127+
if (options.email) url.searchParams.set('email', options.email)
128+
if (options.team) url.searchParams.set('team', options.team)
129+
if (options.role) url.searchParams.set('role', options.role)
130+
if (options.expiresAt) {
131+
const date = options.expiresAt instanceof Date ? options.expiresAt : new Date(options.expiresAt)
132+
if (Number.isNaN(date.valueOf()))
133+
throw new Error('Invite expiration must be a valid date')
134+
url.searchParams.set('expires', date.toISOString())
135+
}
136+
return url.toString()
137+
}
138+
139+
export function createUpdateManifestUrl(baseUrl: string, channel = 'stable'): string {
140+
if (!/^[a-z0-9-]+$/i.test(channel))
141+
throw new Error('Update channel may only contain letters, numbers, and hyphens')
142+
return new URL(`/desktop/updates/${channel}.json`, /^https?:\/\//.test(baseUrl) ? baseUrl : `https://${baseUrl}`).toString()
143+
}
144+
87145
async function launchCraft(command: string[]): Promise<void> {
88146
const process = Bun.spawn(command, {
89147
stdin: 'inherit',
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { existsSync, readFileSync } from 'node:fs'
2+
import { dirname, join } from 'node:path'
3+
4+
interface DesktopManifest {
5+
url: string
6+
title: string
7+
width: number
8+
height: number
9+
darkMode?: boolean
10+
systemTray?: boolean
11+
hideDockIcon?: boolean
12+
}
13+
14+
const bundleDir = dirname(process.execPath)
15+
const manifestPath = join(bundleDir, 'desktop.json')
16+
const craftPath = join(bundleDir, process.platform === 'win32' ? 'craft-runtime.exe' : 'craft-runtime')
17+
18+
if (!existsSync(manifestPath))
19+
throw new Error(`Desktop manifest not found: ${manifestPath}`)
20+
if (!existsSync(craftPath))
21+
throw new Error(`Craft runtime not found: ${craftPath}`)
22+
23+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as DesktopManifest
24+
const command = [
25+
craftPath,
26+
manifest.url,
27+
'--title',
28+
manifest.title,
29+
'--width',
30+
String(manifest.width),
31+
'--height',
32+
String(manifest.height),
33+
'--no-devtools',
34+
]
35+
if (manifest.darkMode) command.push('--dark')
36+
if (manifest.systemTray) command.push('--system-tray')
37+
if (manifest.hideDockIcon) command.push('--hide-dock-icon')
38+
39+
const child = Bun.spawn(command, {
40+
stdin: 'inherit',
41+
stdout: 'inherit',
42+
stderr: 'inherit',
43+
})
44+
process.exit(await child.exited)

storage/framework/core/desktop/tests/desktop.test.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,7 @@ describe('desktop module', () => {
3131
command = args
3232
})
3333
expect(result).toBe(true)
34-
expect(command).toEqual([
35-
'craft',
34+
expect(command.slice(1)).toEqual([
3635
'https://localhost:8080',
3736
'--title',
3837
'Dev',
@@ -41,11 +40,39 @@ describe('desktop module', () => {
4140
'--height',
4241
'768',
4342
'--hot-reload',
44-
'--dev-tools',
45-
'--dark-mode',
43+
'--dark',
4644
])
4745
})
4846

47+
test('uses Craft native tray flags', async () => {
48+
const { craftDevCommand } = await import('../src/index')
49+
const command = craftDevCommand(3009, {
50+
url: 'https://tray.stacks.test',
51+
systemTray: true,
52+
hideDockIcon: true,
53+
menubarOnly: true,
54+
devTools: false,
55+
})
56+
expect(command).toContain('--system-tray')
57+
expect(command).toContain('--hide-dock-icon')
58+
expect(command).toContain('--menubar-only')
59+
expect(command).toContain('--no-devtools')
60+
})
61+
62+
test('builds typed invite and update URLs', async () => {
63+
const { createInviteLink, createUpdateManifestUrl } = await import('../src/index')
64+
const invite = new URL(createInviteLink('app.example.com', 'secret', {
65+
email: 'hello@example.com',
66+
team: 'framework',
67+
role: 'admin',
68+
expiresAt: '2026-08-01T00:00:00.000Z',
69+
}))
70+
expect(invite.pathname).toBe('/invite')
71+
expect(invite.searchParams.get('token')).toBe('secret')
72+
expect(invite.searchParams.get('email')).toBe('hello@example.com')
73+
expect(createUpdateManifestUrl('app.example.com')).toBe('https://app.example.com/desktop/updates/stable.json')
74+
})
75+
4976
test('uses the Stacks pretty URL as the zero-config default', async () => {
5077
const { resolveDevWindowUrl } = await import('../src/index')
5178
const previousAppUrl = process.env.APP_URL
@@ -65,5 +92,6 @@ describe('desktop module', () => {
6592
const exportKeys = Object.keys(mod)
6693
expect(exportKeys).toContain('openDevWindow')
6794
expect(exportKeys).toContain('craftDevCommand')
95+
expect(exportKeys).toContain('resolveCraftBinary')
6896
})
6997
})

0 commit comments

Comments
 (0)