Add CUTOS capability and core API examples

This commit is contained in:
yankun
2026-07-26 14:40:42 +08:00
commit 7cc505a5e2
61 changed files with 5136 additions and 0 deletions
+340
View File
@@ -0,0 +1,340 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { CoreAPI } from '@cutos/core'
import CutosTopbar from '@/components/CutosTopbar.vue'
import { loadConfig } from '@/utils/config'
type ApiKey = 'platform' | 'runtimeConfig' | 'box' | 'device'
type LogLevel = 'info' | 'warning' | 'error' | 'debug'
interface RuntimeResult {
key: ApiKey
method: string
label: string
value: unknown
error: string
loading: boolean
}
interface NotificationMessage {
event?: string
msg?: unknown
}
interface ActivityItem {
id: number
time: string
kind: string
message: string
}
const title = ref('CUTOS Core API')
const lwaName = ref('')
const lwaVersion = ref('')
const coreVersion = ref('')
const host = ref('localhost')
const connected = ref(false)
const initializing = ref(true)
const initError = ref('')
const volume = ref(50)
const volumeResult = ref('')
const proxyResult = ref<unknown>(null)
const proxyLoading = ref(false)
const proxyCompleted = ref(false)
const pingResult = ref<unknown>(null)
const pingLoading = ref(false)
const activities = ref<ActivityItem[]>([])
const results = reactive<RuntimeResult[]>([
{ key: 'platform', method: 'CoreAPI.getPlatform()', label: 'Platform', value: null, error: '', loading: true },
{ key: 'runtimeConfig', method: 'CoreAPI.getConfig()', label: 'Runtime config', value: null, error: '', loading: true },
{ key: 'box', method: 'CoreAPI.getBoxInfo()', label: 'Box', value: null, error: '', loading: true },
{ key: 'device', method: 'CoreAPI.getDeviceInfo()', label: 'Device', value: null, error: '', loading: true }
])
const statusLabel = computed(() => {
if (initializing.value) return 'Connecting'
if (connected.value) return 'Connected'
return 'Disconnected'
})
function format(value: unknown) {
if (value === null || value === undefined) return 'No data'
if (typeof value === 'string') return value
return JSON.stringify(value, null, 2)
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error)
}
function addActivity(kind: string, message: string) {
activities.value.unshift({
id: Date.now() + Math.random(),
time: new Date().toLocaleTimeString(),
kind,
message
})
activities.value = activities.value.slice(0, 8)
}
async function loadResult(key: ApiKey, operation: () => Promise<unknown>) {
const item = results.find((result) => result.key === key)
if (!item) return
item.loading = true
item.error = ''
try {
item.value = await operation()
} catch (error) {
item.error = errorMessage(error)
} finally {
item.loading = false
}
}
async function refreshRuntime() {
await Promise.all([
loadResult('platform', () => CoreAPI.getPlatform()),
loadResult('runtimeConfig', () => CoreAPI.getConfig()),
loadResult('box', () => CoreAPI.getBoxInfo()),
loadResult('device', () => CoreAPI.getDeviceInfo())
])
addActivity('runtime', 'Runtime information refreshed')
}
async function readVolume() {
try {
const current = await CoreAPI.getVolume<number>()
volume.value = Number(current)
} catch (error) {
volumeResult.value = errorMessage(error)
}
}
async function applyVolume() {
volumeResult.value = ''
try {
const result = await CoreAPI.setVolume(volume.value)
volumeResult.value = format(result)
addActivity('volume', `Volume set to ${volume.value}`)
} catch (error) {
volumeResult.value = errorMessage(error)
}
}
function writeLog(level: LogLevel) {
const logger = CoreAPI.getLogger()
const content = `Core API demo ${level} message at ${new Date().toISOString()}`
try {
logger[level]('demo-core-api', content)
addActivity(`log.${level}`, content)
} catch (error) {
addActivity(`log.${level}.error`, errorMessage(error))
}
}
async function configureProxy() {
proxyLoading.value = true
proxyCompleted.value = false
proxyResult.value = null
try {
proxyResult.value = await CoreAPI.setHttpProxy('api', 'https://www.cut-os.com')
addActivity('proxy', 'HTTP proxy configured')
} catch (error) {
proxyResult.value = { error: errorMessage(error) }
} finally {
proxyLoading.value = false
proxyCompleted.value = true
}
}
async function pingProxy() {
pingLoading.value = true
pingResult.value = null
try {
const response = await fetch('http://localhost:3000/proxy/api/rest/sv/system/runtime/ping')
const contentType = response.headers.get('content-type') || ''
const data = contentType.includes('application/json')
? await response.json()
: await response.text()
pingResult.value = {
ok: response.ok,
status: response.status,
statusText: response.statusText,
data
}
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`.trim())
}
addActivity('proxy.ping', 'HTTP proxy ping succeeded')
} catch (error) {
const message = errorMessage(error)
pingResult.value = { error: message, response: pingResult.value }
addActivity('proxy.ping.error', message)
} finally {
pingLoading.value = false
}
}
const notification = CoreAPI.getNotification()
onMounted(async () => {
const config = await loadConfig()
title.value = config.params.title
lwaName.value = config.name
lwaVersion.value = config.version
host.value = import.meta.env.VITE_CUTOS_BROKER_URL || config.params.host
try {
await CoreAPI.init(host.value)
connected.value = CoreAPI.connected()
coreVersion.value = CoreAPI.getVersion()
addActivity('core', `Core initialized at ${host.value}`)
notification.register((data) => {
const message = data as NotificationMessage
if (message.event === 'networkConnection') {
connected.value = Boolean(message.msg)
}
addActivity(`notification.${message.event || 'event'}`, format(message.msg))
})
await Promise.all([refreshRuntime(), readVolume(), configureProxy()])
} catch (error) {
connected.value = false
initError.value = errorMessage(error)
} finally {
initializing.value = false
}
})
onUnmounted(() => notification.unregister())
</script>
<template>
<div class="app-shell">
<CutosTopbar
:title="title"
subtitle="Core API · CUTOS 4.0"
:core-version="coreVersion"
:lwa-name="lwaName"
:lwa-version="lwaVersion"
:connected="connected"
:status-label="statusLabel"
/>
<main>
<section v-if="initError" class="error-banner">
<strong>Core initialization failed</strong>
<span>{{ initError }}</span>
</section>
<section class="runtime-section">
<div class="section-heading">
<div>
<span class="eyebrow">{{ host }}</span>
<h2>Runtime snapshot</h2>
</div>
<button type="button" class="button secondary" :disabled="!connected" @click="refreshRuntime">
Refresh
</button>
</div>
<div class="result-grid">
<article v-for="item in results" :key="item.key" class="result-card">
<div class="result-header">
<h3>{{ item.label }}</h3>
<code>{{ item.method }}</code>
</div>
<pre v-if="item.loading" class="pending">Loading</pre>
<pre v-else-if="item.error" class="result-error">{{ item.error }}</pre>
<pre v-else>{{ format(item.value) }}</pre>
</article>
<article class="result-card activity-card">
<div class="result-header activity-header">
<div>
<h3>Activity</h3>
<code>CoreAPI.getNotification()</code>
</div>
<button v-if="activities.length" type="button" class="text-button" @click="activities = []">Clear</button>
</div>
<div v-if="activities.length" class="activity-list">
<div v-for="item in activities" :key="item.id" class="activity-row">
<time>{{ item.time }}</time>
<code>{{ item.kind }}</code>
<span>{{ item.message }}</span>
</div>
</div>
<p v-else class="empty-state">No activity</p>
</article>
</div>
</section>
<section class="controls-section">
<div class="control-panel volume-panel">
<div class="panel-heading card-style-heading">
<div>
<h2>Audio</h2>
<span class="eyebrow">CoreAPI.getVolume() / setVolume()</span>
</div>
<strong>{{ volume }}</strong>
</div>
<input v-model.number="volume" type="range" min="0" max="100" step="1" :disabled="!connected" />
<div class="panel-actions">
<button type="button" class="button primary" :disabled="!connected" @click="applyVolume">Apply volume</button>
<span class="inline-result">{{ volumeResult }}</span>
</div>
</div>
<div class="control-panel logger-panel">
<div class="panel-heading card-style-heading">
<div>
<h2>Logger</h2>
<span class="eyebrow">CoreAPI.getLogger()</span>
</div>
</div>
<div class="button-row">
<button type="button" class="button secondary" :disabled="!connected" @click="writeLog('info')">Info</button>
<button type="button" class="button secondary" :disabled="!connected" @click="writeLog('warning')">Warning</button>
<button type="button" class="button danger" :disabled="!connected" @click="writeLog('error')">Error</button>
<button type="button" class="button secondary" :disabled="!connected" @click="writeLog('debug')">Debug</button>
</div>
</div>
<div class="control-panel proxy-panel">
<div class="panel-heading card-style-heading">
<div>
<h2>HTTP proxy</h2>
<span class="eyebrow">CoreAPI.setHttpProxy()</span>
</div>
</div>
<div class="proxy-columns">
<div class="proxy-column">
<div class="proxy-target"><code>api https://www.cut-os.com</code></div>
<pre v-if="proxyLoading" class="compact-result pending">Configuring</pre>
<pre v-else-if="proxyCompleted" class="compact-result">{{ format(proxyResult) }}</pre>
</div>
<div class="proxy-column ping-column">
<div class="ping-actions">
<button
type="button"
class="button secondary"
:disabled="!connected || proxyLoading || pingLoading"
@click="pingProxy"
>
{{ pingLoading ? 'Pinging…' : 'Ping' }}
</button>
<p class="ping-hint">Verify access through <code>/proxy/api/</code></p>
</div>
<pre v-if="pingResult" class="compact-result">{{ format(pingResult) }}</pre>
</div>
</div>
</div>
</section>
</main>
</div>
</template>
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

@@ -0,0 +1,75 @@
<script setup lang="ts">
import cutosLogo from '@/assets/cutos.png'
const props = withDefaults(defineProps<{
title: string
subtitle?: string
coreVersion?: string
lwaName?: string
lwaVersion?: string
connected: boolean
statusLabel?: string
}>(), {
subtitle: 'CUTOS 4.0 LWA',
coreVersion: '',
lwaName: '',
lwaVersion: '',
statusLabel: ''
})
</script>
<template>
<header class="topbar">
<div class="brand">
<img :src="cutosLogo" alt="CUTOS" />
<div>
<h1>{{ title }}</h1>
<p>{{ subtitle }}</p>
</div>
</div>
<div class="status-cluster">
<span class="version-tag">Core {{ coreVersion || '—' }}</span>
<span class="version-tag">LWA {{ lwaName }} {{ lwaVersion || '—' }}</span>
<span class="connection" :class="{ online: connected }">
<i></i>{{ statusLabel || (connected ? 'Connected' : 'Disconnected') }}
</span>
</div>
</header>
</template>
<style scoped>
.topbar {
min-height: 82px;
padding: 14px clamp(18px, 4vw, 52px);
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
color: #f8fafb;
background: #20262c;
border-bottom: 4px solid #16a394;
}
.brand { display: flex; align-items: center; gap: 15px; min-width: 0; }
.brand img { width: 42px; height: 42px; object-fit: contain; }
.brand h1 { margin: 0; font-size: 20px; line-height: 1.2; font-weight: 700; }
.brand p { margin: 4px 0 0; font-size: 12px; color: #aeb8bf; }
.status-cluster { display: flex; align-items: center; justify-content: flex-end; gap: 9px; flex-wrap: wrap; }
.version-tag, .connection {
height: 30px;
display: inline-flex;
align-items: center;
padding: 0 10px;
font-size: 12px;
white-space: nowrap;
border: 1px solid #4b555d;
border-radius: 4px;
background: #2b3339;
}
.connection i { width: 8px; height: 8px; margin-right: 7px; border-radius: 50%; background: #e06b65; }
.connection.online i { background: #42c6a8; box-shadow: 0 0 0 3px rgba(66, 198, 168, 0.14); }
@media (max-width: 640px) {
.topbar { align-items: flex-start; flex-direction: column; gap: 12px; }
.status-cluster { justify-content: flex-start; }
}
</style>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')
+100
View File
@@ -0,0 +1,100 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: Inter, "Segoe UI", Arial, sans-serif;
color: #20262c;
background: #eef1f3;
font-synthesis: none;
text-rendering: optimizeLegibility;
letter-spacing: 0;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button, input { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: not-allowed; opacity: 0.48; }
.app-shell { min-height: 100vh; background: #eef1f3; }
main { width: 100%; margin: 0 auto; padding: 28px clamp(18px, 2vw, 36px) 32px; }
section + section { margin-top: 34px; }
.error-banner { display: flex; gap: 12px; padding: 12px 16px; color: #7e2420; background: #fff0ef; border-left: 4px solid #d54d47; }
.section-heading, .panel-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; }
.section-heading { margin-bottom: 15px; }
.section-heading h2, .panel-heading h2 { margin: 3px 0 0; font-size: 18px; line-height: 1.2; }
.card-style-heading h2 { margin: 0 0 6px; }
.eyebrow { display: block; color: #64717a; font-family: "Cascadia Code", Consolas, monospace; font-size: 11px; }
.result-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.result-card { min-width: 0; min-height: 224px; background: #fff; border: 1px solid #d8dee2; border-radius: 6px; overflow: hidden; }
.result-header { min-height: 66px; padding: 13px 15px; border-bottom: 1px solid #e4e8eb; background: #f8fafb; }
.result-header h3 { margin: 0 0 6px; font-size: 14px; }
.result-header code { display: block; color: #68757d; font-size: 11px; overflow-wrap: anywhere; }
pre { margin: 0; padding: 15px; font: 12px/1.55 "Cascadia Code", Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
.result-card pre { max-height: 220px; overflow: auto; }
.pending { color: #75828a; }
.result-error { color: #b43b35; }
.activity-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.activity-card { grid-column: span 2; }
.activity-header h3 { margin-bottom: 6px; }
.controls-section { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-top: 1px solid #cbd3d8; border-bottom: 1px solid #cbd3d8; }
.control-panel { min-width: 0; padding: 22px; background: #f7f9fa; }
.control-panel + .control-panel { border-left: 1px solid #cbd3d8; }
.panel-heading strong { font-size: 28px; color: #138b80; }
.volume-panel input[type="range"] { width: 100%; margin: 28px 0 20px; accent-color: #138b80; }
.panel-actions { min-height: 34px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.inline-result { max-width: 100%; color: #64717a; font: 11px/1.4 "Cascadia Code", Consolas, monospace; overflow-wrap: anywhere; }
.button-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 25px; }
.button { min-height: 34px; padding: 7px 13px; border: 1px solid transparent; border-radius: 4px; font-weight: 650; font-size: 12px; }
.button.primary { color: #fff; background: #138b80; border-color: #138b80; }
.button.secondary { color: #2d373d; background: #fff; border-color: #bfc8cd; }
.button.danger { color: #a12d28; background: #fff; border-color: #dba7a4; }
.proxy-panel { grid-column: span 2; }
.proxy-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 18px; }
.proxy-column { min-width: 0; }
.ping-column { min-width: 0; }
.ping-actions { min-height: 44px; display: flex; align-items: center; gap: 10px; }
.proxy-target { padding: 9px 10px; background: #fff; border: 1px solid #d8dee2; overflow-wrap: anywhere; }
.proxy-target code { font-size: 11px; }
.compact-result { width: 100%; margin-top: 10px; padding: 9px 10px; background: #fff; border: 1px solid #d8dee2; max-height: 82px; overflow: auto; }
.ping-column .compact-result { margin-top: 10px; }
.ping-hint { margin: 0; color: #64717a; font-size: 11px; line-height: 1.5; }
.ping-hint code { overflow-wrap: anywhere; }
.text-button { padding: 4px 0; border: 0; color: #138b80; background: transparent; font-size: 12px; }
.activity-list { max-height: 157px; overflow: auto; }
.activity-row { display: grid; grid-template-columns: 82px 240px minmax(0, 1fr); gap: 12px; padding: 10px 13px; font-size: 12px; border-bottom: 1px solid #edf0f2; }
.activity-row:last-child { border-bottom: 0; }
.activity-row time { color: #77838b; font-variant-numeric: tabular-nums; }
.activity-row code { color: #087b71; overflow-wrap: anywhere; }
.activity-row span { min-width: 0; overflow-wrap: anywhere; }
.empty-state { margin: 0; padding: 24px 15px; color: #77838b; font-size: 13px; }
@media (min-width: 1400px) and (min-height: 800px) {
main { min-height: calc(100vh - 82px); display: flex; flex-direction: column; }
.runtime-section { flex: 1; display: flex; flex-direction: column; }
.result-grid { flex: 1; grid-template-rows: repeat(2, minmax(0, 1fr)); }
.result-card { min-height: 0; }
.controls-section { min-height: 220px; }
.control-panel { display: flex; flex-direction: column; justify-content: center; }
}
@media (max-width: 980px) {
.result-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.activity-card { grid-column: auto; }
.controls-section { grid-template-columns: 1fr; }
.proxy-panel { grid-column: auto; }
.control-panel + .control-panel { border-left: 0; border-top: 1px solid #cbd3d8; }
}
@media (max-width: 640px) {
.result-grid { grid-template-columns: 1fr; }
.result-card { min-height: 190px; }
.activity-row { grid-template-columns: 70px minmax(0, 1fr); }
.activity-row span { grid-column: 1 / -1; }
.proxy-columns { grid-template-columns: 1fr; }
}
@@ -0,0 +1,66 @@
export interface LwaConfig {
name: string
version: string
description?: string
params: {
title: string
host: string
[key: string]: unknown
}
drvDependencies?: Record<string, string>
}
const defaultConfig: LwaConfig = {
name: 'cutos-lwa',
version: '0.0.0',
params: {
title: 'CUTOS LWA',
host: 'localhost'
}
}
export let config: LwaConfig = { ...defaultConfig, params: { ...defaultConfig.params } }
function parse(str?: string) {
if (!str || !str.length) return {}
return str.split('&').reduce<Record<string, string>>((acc, row) => {
const [key, value] = row.split('=').map(decodeURIComponent)
acc[key] = value
return acc
}, {})
}
function mergeQueryParams(config: LwaConfig) {
if (typeof window === 'undefined') {
return config
}
const query = location.href.split('?')[1]
const { params } = parse(query) as { params?: string }
try {
if (params) config.params = { ...config.params, ...JSON.parse(params) }
} catch (error) {
console.error(error)
}
return config
}
export async function loadConfig(): Promise<LwaConfig> {
try {
const configUrl =
typeof window === 'undefined' ? 'config.json' : new URL('config.json', window.location.href).toString()
const response = await fetch(configUrl, { cache: 'no-cache' })
if (!response.ok) {
throw new Error(`Failed to load config.json: ${response.status}`)
}
config = mergeQueryParams((await response.json()) as LwaConfig)
return config
} catch (error) {
console.error(error)
config = mergeQueryParams({ ...defaultConfig, params: { ...defaultConfig.params } })
return config
}
}
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_CUTOS_BROKER_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}