diff --git a/package.json b/package.json index 1b80845..c333284 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "vite": "^8.0.7" }, "dependencies": { - "@tauri-apps/api": "^2.0.0" + "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-os": "^2.3.2", + "phosphor-svelte": "^3.1.0" } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8842c45..376cca2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@tauri-apps/api': specifier: ^2.0.0 version: 2.11.0 + '@tauri-apps/plugin-os': + specifier: ^2.3.2 + version: 2.3.2 + phosphor-svelte: + specifier: ^3.1.0 + version: 3.1.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10) devDependencies: '@sveltejs/adapter-node': specifier: ^5.5.4 @@ -471,6 +477,9 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-os@2.3.2': + resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -550,6 +559,9 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -687,6 +699,15 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + phosphor-svelte@3.1.0: + resolution: {integrity: sha512-nldtxx+XCgNREvrb7O5xgDsefytXpSkPTx8Rnu3f2qQCUZLDV1rLxYSd2Jcwckuo9lZB1qKMqGR17P4UDC0PrA==} + peerDependencies: + svelte: ^5.0.0 || ^5.0.0-next.96 + vite: '>=5' + peerDependenciesMeta: + vite: + optional: true + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1124,6 +1145,10 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.11.2 '@tauri-apps/cli-win32-x64-msvc': 2.11.2 + '@tauri-apps/plugin-os@2.3.2': + dependencies: + '@tauri-apps/api': 2.11.0 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -1176,6 +1201,10 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -1270,6 +1299,14 @@ snapshots: path-parse@1.0.7: {} + phosphor-svelte@3.1.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10): + dependencies: + estree-walker: 3.0.3 + magic-string: 0.30.21 + svelte: 5.55.5(@typescript-eslint/types@8.57.1) + optionalDependencies: + vite: 8.0.10 + picocolors@1.1.1: {} picomatch@4.0.4: {} diff --git a/src/hooks.client.ts b/src/hooks.client.ts index 73d5fb9..f1b4f69 100644 --- a/src/hooks.client.ts +++ b/src/hooks.client.ts @@ -1,6 +1,16 @@ import { initRequestManager } from '$lib/request-manager' import { initPlatformService } from '$lib/platform-service' import { appState } from '$lib/state/app.svelte' +import { configureAuth, probeServer } from '$lib/core/auth' + +const SAVED_URL_KEY = 'moku_server_url' +const SAVED_AUTH_KEY = 'moku_auth_config' + +interface SavedAuth { + mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' + user?: string + pass?: string +} function isTauri(): boolean { return '__TAURI_INTERNALS__' in window @@ -10,6 +20,18 @@ function isCapacitor(): boolean { return 'Capacitor' in window } +function loadSavedServerUrl(): string { + return localStorage.getItem(SAVED_URL_KEY) ?? 'http://127.0.0.1:4567' +} + +function loadSavedAuth(): SavedAuth { + try { + return JSON.parse(localStorage.getItem(SAVED_AUTH_KEY) ?? 'null') ?? { mode: 'NONE' } + } catch { + return { mode: 'NONE' } + } +} + async function resolvePlatformAdapter() { if (isTauri()) { const { TauriAdapter } = await import('$lib/platform-adapters/tauri') @@ -39,12 +61,40 @@ async function boot() { initPlatformService(platformAdapter) appState.platform = isTauri() ? 'tauri' : isCapacitor() ? 'capacitor' : 'web' - appState.version = await platformAdapter.getVersion() - appState.status = 'ready' + appState.version = await platformAdapter.getVersion() + + const savedUrl = loadSavedServerUrl() + const savedAuth = loadSavedAuth() + + appState.serverUrl = savedUrl + appState.authMode = savedAuth.mode + + if (isTauri() && platformAdapter.isSupported('server-management')) { + await platformAdapter.launchServer({ url: savedUrl }).catch(() => {}) + } + + configureAuth(savedUrl, savedAuth.mode, savedAuth.user, savedAuth.pass) + await serverAdapter.connect({ baseUrl: savedUrl }) + + const probe = await probeServer() + + if (probe === 'auth_required') { + appState.status = 'auth' + return + } + + if (probe === 'unreachable') { + appState.error = `Could not reach server at ${savedUrl}` + appState.status = 'error' + return + } + + appState.authenticated = true + appState.status = 'ready' } catch (e) { - appState.error = String(e) + appState.error = String(e) appState.status = 'error' } } -boot() +boot() \ No newline at end of file diff --git a/src/lib/assets/moku-icon-full.svg b/src/lib/assets/moku-icon-full.svg new file mode 100644 index 0000000..23b0e20 --- /dev/null +++ b/src/lib/assets/moku-icon-full.svg @@ -0,0 +1,22 @@ + + + + + + diff --git a/src/lib/assets/moku-icon-splash.svg b/src/lib/assets/moku-icon-splash.svg new file mode 100644 index 0000000..e88a390 --- /dev/null +++ b/src/lib/assets/moku-icon-splash.svg @@ -0,0 +1,21 @@ + + + + + diff --git a/src/lib/assets/moku-icon-wordmark.svg b/src/lib/assets/moku-icon-wordmark.svg new file mode 100644 index 0000000..74e9ef2 --- /dev/null +++ b/src/lib/assets/moku-icon-wordmark.svg @@ -0,0 +1,21 @@ + + + + + diff --git a/src/lib/assets/moku-icon.svg b/src/lib/assets/moku-icon.svg new file mode 100644 index 0000000..f522609 --- /dev/null +++ b/src/lib/assets/moku-icon.svg @@ -0,0 +1,22 @@ + + + + + + diff --git a/src/lib/core/auth.ts b/src/lib/core/auth.ts new file mode 100644 index 0000000..d486719 --- /dev/null +++ b/src/lib/core/auth.ts @@ -0,0 +1,388 @@ +export type AuthMode = 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' + +export class AuthRequiredError extends Error { + constructor(msg = 'Authentication required') { + super(msg) + this.name = 'AuthRequiredError' + } +} + +const TOKEN_KEY = 'moku_access_token' +const UI_SESSION_KEY = 'moku_ui_auth_session' +const REFRESH_SKEW_MS = 30_000 + +interface StoredToken { + base: string + token: string +} + +interface UiSession { + base: string + accessToken: string + refreshToken?: string + clientMutationId?: string + accessExpiresAt?: number | null + refreshExpiresAt?: number | null +} + +interface JwtSettings { + jwtAudience?: string | null + jwtRefreshExpiry?: string | null + jwtTokenExpiry?: string | null +} + +let _session: UiSession | null = null +let _accessToken: string | null = null +let _accessTokenBase: string | null = null +let _refreshPromise: Promise | null = null +let _jwtSettings: JwtSettings | null = null +let _jwtSettingsBase: string | null = null +let _jwtSettingsFetchedAt = 0 + +let _serverBase = 'http://127.0.0.1:4567' +let _authMode: AuthMode = 'NONE' +let _basicUser = '' +let _basicPass = '' + +export function configureAuth(base: string, mode: AuthMode, user = '', pass = '') { + _serverBase = base.replace(/\/$/, '') + _authMode = mode + _basicUser = user + _basicPass = pass +} + +export function getServerBase(): string { + return _serverBase +} + +export function getAuthMode(): AuthMode { + return _authMode +} + +function timeoutSignal(ms: number): AbortSignal { + return AbortSignal.timeout(ms) +} + +function gqlBody(query: string, variables?: Record): string { + return JSON.stringify({ query, variables }) +} + +function basicHeader(user: string, pass: string): Record { + return { Authorization: 'Basic ' + btoa(`${user}:${pass}`) } +} + +function bearerHeader(token: string): Record { + return { Authorization: `Bearer ${token}` } +} + +function parseIsoDuration(d: string): number | null { + const m = d.match(/^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?)?$/) + if (!m) return null + let ms = 0 + if (m[1]) ms += +m[1] * 365.25 * 86400000 + if (m[2]) ms += +m[2] * 30.44 * 86400000 + if (m[3]) ms += +m[3] * 86400000 + if (m[4]) ms += +m[4] * 3600000 + if (m[5]) ms += +m[5] * 60000 + if (m[6]) ms += parseFloat(m[6]) * 1000 + return ms +} + +function decodeJwtExpiry(token: string): number | null { + try { + const part = token.split('.')[1] + if (!part) return null + const pad = part.replace(/-/g, '+').replace(/_/g, '/') + const json = JSON.parse(atob(pad.padEnd(pad.length + ((4 - pad.length % 4) % 4), '='))) as { exp?: number } + return typeof json.exp === 'number' ? json.exp * 1000 : null + } catch { return null } +} + +function isExpired(at?: number | null, skew = REFRESH_SKEW_MS): boolean { + if (!at || !Number.isFinite(at)) return false + return Date.now() >= at - skew +} + +function readStoredSession(): UiSession | null { + try { return JSON.parse(sessionStorage.getItem(UI_SESSION_KEY) ?? 'null') } catch { return null } +} + +function readStoredToken(): StoredToken | null { + try { return JSON.parse(sessionStorage.getItem(TOKEN_KEY) ?? 'null') } catch { return null } +} + +export const uiAuth = { + getSession(): UiSession | null { + if (_session?.base === _serverBase) return _session + const stored = readStoredSession() + if (!stored || stored.base !== _serverBase) { + sessionStorage.removeItem(UI_SESSION_KEY) + sessionStorage.removeItem(TOKEN_KEY) + _session = _accessToken = _accessTokenBase = null + return null + } + _session = stored + _accessToken = stored.accessToken + _accessTokenBase = stored.base + return _session + }, + + setSession(session: Omit) { + _session = { ...session, base: _serverBase } + _accessToken = session.accessToken + _accessTokenBase = _serverBase + sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(_session)) + sessionStorage.removeItem(TOKEN_KEY) + }, + + getToken(): string | null { + const s = uiAuth.getSession() + if (!s || isExpired(s.accessExpiresAt, 0)) return null + if (_accessToken && _accessTokenBase === _serverBase) return _accessToken + const stored = readStoredToken() + if (!stored || stored.base !== _serverBase) { + sessionStorage.removeItem(TOKEN_KEY) + _accessToken = _accessTokenBase = null + return null + } + _accessToken = stored.token + _accessTokenBase = stored.base + return _accessToken + }, + + setToken(t: string) { + const existing = uiAuth.getSession() + if (existing?.refreshToken) { + uiAuth.setSession({ ...existing, accessToken: t, ...expiryFromJwt(t, _jwtSettings) }) + return + } + _accessToken = t + _accessTokenBase = _serverBase + sessionStorage.setItem(TOKEN_KEY, JSON.stringify({ base: _serverBase, token: t })) + }, + + setLoginSession( + payload: { accessToken: string; refreshToken: string; clientMutationId?: string }, + jwt: JwtSettings | null, + ) { + uiAuth.setSession({ + accessToken: payload.accessToken, + refreshToken: payload.refreshToken, + clientMutationId: payload.clientMutationId, + ...expiryFromJwt(payload.accessToken, jwt), + }) + }, + + updateAccessToken( + payload: { accessToken: string; clientMutationId?: string }, + jwt: JwtSettings | null, + ) { + const s = uiAuth.getSession() + if (!s) return + uiAuth.setSession({ + ...s, + accessToken: payload.accessToken, + clientMutationId: payload.clientMutationId ?? s.clientMutationId, + ...expiryFromJwt(payload.accessToken, jwt), + }) + }, + + clearToken() { + _session = _accessToken = _accessTokenBase = null + sessionStorage.removeItem(UI_SESSION_KEY) + sessionStorage.removeItem(TOKEN_KEY) + }, +} + +function expiryFromJwt(token: string, jwt: JwtSettings | null) { + const now = Date.now() + return { + accessExpiresAt: decodeJwtExpiry(token) ?? (jwt?.jwtTokenExpiry ? now + (parseIsoDuration(jwt.jwtTokenExpiry) ?? 0) : null), + refreshExpiresAt: jwt?.jwtRefreshExpiry ? now + (parseIsoDuration(jwt.jwtRefreshExpiry) ?? 0) : null, + } +} + +async function fetchJwtSettings(): Promise { + try { + const res = await fetchAuthenticated(`${_serverBase}/api/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: gqlBody(`query { settings { jwtAudience jwtRefreshExpiry jwtTokenExpiry } }`), + }, timeoutSignal(5000)) + if (!res.ok) return null + const json = await res.json() + const s = json?.data?.settings + if (!s) return null + return { + jwtAudience: s.jwtAudience ?? null, + jwtRefreshExpiry: s.jwtRefreshExpiry ?? null, + jwtTokenExpiry: s.jwtTokenExpiry ?? null, + } + } catch { return null } +} + +async function getJwtSettings(force = false): Promise { + const fresh = Date.now() - _jwtSettingsFetchedAt < 60_000 + if (!force && _jwtSettingsBase === _serverBase && _jwtSettings && fresh) return _jwtSettings + _jwtSettings = await fetchJwtSettings() + _jwtSettingsBase = _serverBase + _jwtSettingsFetchedAt = Date.now() + return _jwtSettings +} + +export async function fetchAuthenticated( + url: string, + init: RequestInit = {}, + signal?: AbortSignal, +): Promise { + const baseHeaders = { ...(init.headers as Record ?? {}) } + + if (_authMode === 'BASIC_AUTH') { + return fetch(url, { + ...init, signal, credentials: 'omit', + headers: { ...baseHeaders, ...(_basicUser && _basicPass ? basicHeader(_basicUser, _basicPass) : {}) }, + }) + } + + if (_authMode === 'UI_LOGIN') { + const token = await getUIAccessToken() + if (!token) throw new AuthRequiredError() + + let res = await fetch(url, { + ...init, signal, credentials: 'omit', + headers: { ...baseHeaders, ...bearerHeader(token) }, + }) + + if (res.status !== 401) return res + + const refreshed = await refreshUiAccessToken(true) + if (!refreshed) return res + + return fetch(url, { + ...init, signal, credentials: 'omit', + headers: { ...baseHeaders, ...bearerHeader(refreshed) }, + }) + } + + return fetch(url, { ...init, signal, credentials: 'omit' }) +} + +export async function getUIAccessToken(forceRefresh = false): Promise { + const s = uiAuth.getSession() + if (!s) return null + if (forceRefresh || isExpired(s.accessExpiresAt)) return refreshUiAccessToken(true) + return s.accessToken +} + +export async function refreshUiAccessToken(force = false): Promise { + const s = uiAuth.getSession() + if (!s) return null + if (!s.refreshToken) { + if (force && isExpired(s.accessExpiresAt, 0)) return null + return s.accessToken + } + if (!force && !isExpired(s.accessExpiresAt)) return s.accessToken + if (isExpired(s.refreshExpiresAt)) { uiAuth.clearToken(); return null } + if (_refreshPromise) return _refreshPromise + + _refreshPromise = (async () => { + const jwt = await getJwtSettings().catch(() => null) + const res = await fetch(`${_serverBase}/api/graphql`, { + method: 'POST', credentials: 'omit', + headers: { 'Content-Type': 'application/json' }, + body: gqlBody( + `mutation RefreshToken($refreshToken: String!, $clientMutationId: String) { + refreshToken(input: { refreshToken: $refreshToken, clientMutationId: $clientMutationId }) { + accessToken clientMutationId + } + }`, + { refreshToken: s.refreshToken, clientMutationId: s.clientMutationId }, + ), + signal: timeoutSignal(5000), + }) + if (!res.ok) { + if (res.status === 401 || res.status === 403) { uiAuth.clearToken(); return null } + throw new Error(`Token refresh failed (${res.status})`) + } + const json = await res.json() + const refreshed = json?.data?.refreshToken + const next: string | undefined = refreshed?.accessToken + if (!next) { uiAuth.clearToken(); return null } + uiAuth.updateAccessToken({ accessToken: next, clientMutationId: refreshed?.clientMutationId }, jwt) + return next + })().finally(() => { _refreshPromise = null }) + + return _refreshPromise +} + +export async function loginUI(user: string, pass: string): Promise { + const res = await fetch(`${_serverBase}/api/graphql`, { + method: 'POST', credentials: 'omit', + headers: { 'Content-Type': 'application/json' }, + body: gqlBody( + `mutation Login($username: String!, $password: String!) { + login(input: { username: $username, password: $password }) { + accessToken refreshToken clientMutationId + } + }`, + { username: user, password: pass }, + ), + signal: timeoutSignal(8000), + }) + if (!res.ok) throw new Error(`Login request failed (${res.status})`) + const json = await res.json() + const payload = json?.data?.login + if (!payload?.accessToken || !payload?.refreshToken) { + throw new Error(json?.errors?.[0]?.message ?? 'Login failed') + } + const jwt = await getJwtSettings(true).catch(() => null) + uiAuth.setLoginSession({ + accessToken: payload.accessToken, + refreshToken: payload.refreshToken, + clientMutationId: typeof payload.clientMutationId === 'string' ? payload.clientMutationId : undefined, + }, jwt) + _authMode = 'UI_LOGIN' + _basicUser = user + _basicPass = '' +} + +export async function loginBasic(user: string, pass: string): Promise { + const res = await fetch(`${_serverBase}/api/graphql`, { + method: 'POST', credentials: 'omit', + headers: { 'Content-Type': 'application/json', ...basicHeader(user, pass) }, + body: gqlBody('{ __typename }'), + signal: timeoutSignal(5000), + }) + if (!res.ok) throw new Error(`Authentication failed (${res.status})`) + _authMode = 'BASIC_AUTH' + _basicUser = user + _basicPass = pass +} + +export async function logout(): Promise { + uiAuth.clearToken() + _authMode = 'NONE' + _basicUser = '' + _basicPass = '' +} + +export async function probeServer(): Promise<'ok' | 'auth_required' | 'unreachable'> { + try { + const headers: Record = { 'Content-Type': 'application/json' } + if (_authMode === 'BASIC_AUTH' && _basicUser && _basicPass) { + Object.assign(headers, basicHeader(_basicUser, _basicPass)) + } else if (_authMode === 'UI_LOGIN') { + const token = await getUIAccessToken() + if (!token) return 'auth_required' + Object.assign(headers, bearerHeader(token)) + } + const res = await fetch(`${_serverBase}/api/graphql`, { + method: 'POST', credentials: 'omit', headers, + body: gqlBody('{ __typename }'), + signal: timeoutSignal(5000), + }) + if (res.ok) return 'ok' + if (res.status === 401) return 'auth_required' + return 'unreachable' + } catch { return 'unreachable' } +} \ No newline at end of file diff --git a/src/lib/state/app.svelte.ts b/src/lib/state/app.svelte.ts index 7e3cb71..ec19fcd 100644 --- a/src/lib/state/app.svelte.ts +++ b/src/lib/state/app.svelte.ts @@ -1,10 +1,11 @@ -export type AppStatus = 'booting' | 'auth' | 'ready' | 'error' +export type AppStatus = 'booting' | 'not-configured' | 'auth' | 'ready' | 'error' export const appState = $state({ - status: 'booting' as AppStatus, - error: null as string | null, - serverUrl: '', - authenticated: false, - platform: 'web' as 'web' | 'tauri' | 'capacitor', - version: '', -}) + status: 'booting' as AppStatus, + error: null as string | null, + serverUrl: '', + authenticated: false, + authMode: 'NONE' as 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN', + platform: 'web' as 'web' | 'tauri' | 'capacitor', + version: '', +}) \ No newline at end of file diff --git a/src/lib/ui/chrome/AuthGate.svelte b/src/lib/ui/chrome/AuthGate.svelte new file mode 100644 index 0000000..fb05126 --- /dev/null +++ b/src/lib/ui/chrome/AuthGate.svelte @@ -0,0 +1,103 @@ + + +{#if appState.status === 'auth'} +
+
+ +

moku

+ + {appState.authMode === 'UI_LOGIN' ? 'UI Login' : 'Basic Auth'} + +

{appState.serverUrl || 'localhost:4567'}

+ + {#if loginError} +

{loginError}

+ {/if} + +
+ e.key === 'Enter' && handleLogin()} + /> + e.key === 'Enter' && handleLogin()} + /> +
+ + + +
+
+{/if} + + \ No newline at end of file diff --git a/src/lib/ui/chrome/Sidebar.svelte b/src/lib/ui/chrome/Sidebar.svelte new file mode 100644 index 0000000..eee7a72 --- /dev/null +++ b/src/lib/ui/chrome/Sidebar.svelte @@ -0,0 +1,173 @@ + + + + + diff --git a/src/lib/ui/chrome/SplashScreen.svelte b/src/lib/ui/chrome/SplashScreen.svelte new file mode 100644 index 0000000..95dcac3 --- /dev/null +++ b/src/lib/ui/chrome/SplashScreen.svelte @@ -0,0 +1,219 @@ + + +
+ {#if showCards} + + {/if} + + {#if mode === 'idle'} +
+
+
+ Moku +
+

press any key to continue

+
+ + {:else} +
+ {#if !failed && !notConfigured} + + + + + {/if} + Moku +
+ +
+
+ {#if failed || notConfigured} +
+

{failed ? 'Could not reach server' : 'Server not configured'}

+
+ + +
+
+ {:else} +

{ringFull ? '' : `Initializing server${dots}`}

+ {/if} +
+
+ {/if} +
+ + diff --git a/src/lib/ui/chrome/TitleBar.svelte b/src/lib/ui/chrome/TitleBar.svelte new file mode 100644 index 0000000..6bef5bc --- /dev/null +++ b/src/lib/ui/chrome/TitleBar.svelte @@ -0,0 +1,134 @@ + + +{#if !isFullscreen} +
+ {#if isMac}
{/if} + Moku + {#if !isMac} +
+ + + +
+ {/if} +
+{:else if isWindows} +
+ + +
+{/if} + + \ No newline at end of file diff --git a/src/lib/ui/chrome/Toaster.svelte b/src/lib/ui/chrome/Toaster.svelte new file mode 100644 index 0000000..dd796f8 --- /dev/null +++ b/src/lib/ui/chrome/Toaster.svelte @@ -0,0 +1,202 @@ + + +{#if toasts.length} +
+ {#each toasts as t (t.id)} + + {/each} +
+{/if} + +{#if detail} + +{/if} + + \ No newline at end of file diff --git a/src/lib/ui/chrome/splashCanvas.ts b/src/lib/ui/chrome/splashCanvas.ts new file mode 100644 index 0000000..aa72af2 --- /dev/null +++ b/src/lib/ui/chrome/splashCanvas.ts @@ -0,0 +1,171 @@ +const CARD_COUNT = 18 +const CARD_W = 52 +const CARD_H = 72 +const CARD_RADIUS = 6 +const DRIFT_SPEED = 0.018 + +interface Card { + x: number + y: number + vx: number + vy: number + rot: number + vrot: number + opacity: number + scale: number + hue: number +} + +function makeCard(w: number, h: number): Card { + const side = Math.floor(Math.random() * 4) + const margin = 80 + let x = 0, y = 0 + if (side === 0) { x = Math.random() * w; y = -margin } + if (side === 1) { x = w + margin; y = Math.random() * h } + if (side === 2) { x = Math.random() * w; y = h + margin } + if (side === 3) { x = -margin; y = Math.random() * h } + const cx = w / 2, cy = h / 2 + const dx = cx - x, dy = cy - y + const len = Math.sqrt(dx * dx + dy * dy) || 1 + const spd = 0.12 + Math.random() * 0.1 + return { + x, + y, + vx: (dx / len) * spd * (0.3 + Math.random() * 0.4), + vy: (dy / len) * spd * (0.3 + Math.random() * 0.4), + rot: Math.random() * Math.PI * 2, + vrot: (Math.random() - 0.5) * 0.006, + opacity: 0.025 + Math.random() * 0.055, + scale: 0.7 + Math.random() * 0.7, + hue: 120 + Math.random() * 40, + } +} + +function drawCard(ctx: CanvasRenderingContext2D, c: Card) { + ctx.save() + ctx.globalAlpha = c.opacity + ctx.translate(c.x, c.y) + ctx.rotate(c.rot) + ctx.scale(c.scale, c.scale) + + const w = CARD_W, h = CARD_H, r = CARD_RADIUS + const x = -w / 2, y = -h / 2 + + ctx.beginPath() + ctx.moveTo(x + r, y) + ctx.lineTo(x + w - r, y) + ctx.quadraticCurveTo(x + w, y, x + w, y + r) + ctx.lineTo(x + w, y + h - r) + ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h) + ctx.lineTo(x + r, y + h) + ctx.quadraticCurveTo(x, y + h, x, y + h - r) + ctx.lineTo(x, y + r) + ctx.quadraticCurveTo(x, y, x + r, y) + ctx.closePath() + + ctx.strokeStyle = `hsla(${c.hue}, 28%, 62%, 0.9)` + ctx.lineWidth = 1 / c.scale + ctx.stroke() + + const grad = ctx.createLinearGradient(x, y, x, y + h) + grad.addColorStop(0, `hsla(${c.hue}, 20%, 40%, 0.18)`) + grad.addColorStop(1, `hsla(${c.hue}, 20%, 20%, 0.06)`) + ctx.fillStyle = grad + ctx.fill() + + ctx.restore() +} + +export function mountCardCanvas(canvas: HTMLCanvasElement) { + const ctx = canvas.getContext('2d')! + let cards = Array.from({ length: CARD_COUNT }, () => makeCard(canvas.offsetWidth || 800, canvas.offsetHeight || 600)) + let raf = 0 + let running = true + + function resize() { + const dpr = window.devicePixelRatio || 1 + canvas.width = canvas.offsetWidth * dpr + canvas.height = canvas.offsetHeight * dpr + ctx.scale(dpr, dpr) + cards = Array.from({ length: CARD_COUNT }, () => makeCard(canvas.offsetWidth, canvas.offsetHeight)) + } + + function tick() { + if (!running) return + const w = canvas.offsetWidth, h = canvas.offsetHeight + ctx.clearRect(0, 0, w, h) + for (const c of cards) { + c.x += c.vx + c.y += c.vy + c.rot += c.vrot + const pad = 120 + if (c.x < -pad || c.x > w + pad || c.y < -pad || c.y > h + pad) { + Object.assign(c, makeCard(w, h)) + } + drawCard(ctx, c) + } + raf = requestAnimationFrame(tick) + } + + const ro = new ResizeObserver(resize) + ro.observe(canvas) + resize() + tick() + + return { + destroy() { + running = false + cancelAnimationFrame(raf) + ro.disconnect() + }, + } +} + +export function ringGeometry(r: number, pad: number) { + const size = (r + pad) * 2 + const c = size / 2 + const circ = 2 * Math.PI * r + return { size, c, circ } +} + +const RING_STEPS = [ + { target: 0.15, duration: 400 }, + { target: 0.45, duration: 800 }, + { target: 0.72, duration: 600 }, + { target: 0.88, duration: 1000 }, + { target: 0.96, duration: 700 }, +] + +export function animateRingProgress(onProgress: (p: number) => void): () => void { + let current = 0.025 + let stepIdx = 0 + let start = performance.now() + let raf = 0 + let stopped = false + + function ease(t: number) { + return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t + } + + function tick(now: number) { + if (stopped) return + if (stepIdx >= RING_STEPS.length) return + + const step = RING_STEPS[stepIdx] + const elapsed = now - start + const t = Math.min(elapsed / step.duration, 1) + const from = stepIdx === 0 ? 0.025 : RING_STEPS[stepIdx - 1].target + current = from + (step.target - from) * ease(t) + onProgress(current) + + if (t >= 1) { + stepIdx++ + start = now + } + + raf = requestAnimationFrame(tick) + } + + raf = requestAnimationFrame(tick) + return () => { stopped = true; cancelAnimationFrame(raf) } +} \ No newline at end of file diff --git a/src/lib/ui/chrome/titlebarOs.ts b/src/lib/ui/chrome/titlebarOs.ts new file mode 100644 index 0000000..db3aed4 --- /dev/null +++ b/src/lib/ui/chrome/titlebarOs.ts @@ -0,0 +1,14 @@ +export type OsKind = 'macos' | 'windows' | 'linux' | 'unknown' + +export async function detectOs(): Promise { + try { + const { platform } = await import('@tauri-apps/plugin-os') + const p = await platform() + if (p === 'macos') return 'macos' + if (p === 'windows') return 'windows' + if (p === 'linux') return 'linux' + return 'unknown' + } catch { + return 'unknown' + } +} \ No newline at end of file diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 34f72f6..609fb45 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,7 +1,92 @@ -{@render children()} +{#if !showApp} + {}} + onBypass={() => (bypassed = true)} + onRetry={() => window.location.reload()} + /> +{/if} + +{#if showApp} +
+
+ {#if isTauri} + import('@tauri-apps/api/window').then(m => m.getCurrentWindow().close())} /> + {/if} +
+ +
+ {@render children()} +
+
+
+
+{/if} + + + + + \ No newline at end of file diff --git a/src/routes/browse/+page.svelte b/src/routes/browse/+page.svelte new file mode 100644 index 0000000..1834d07 --- /dev/null +++ b/src/routes/browse/+page.svelte @@ -0,0 +1 @@ +

browse

\ No newline at end of file diff --git a/src/routes/downloads/+page.svelte b/src/routes/downloads/+page.svelte new file mode 100644 index 0000000..4c7d13c --- /dev/null +++ b/src/routes/downloads/+page.svelte @@ -0,0 +1 @@ +

downloads

\ No newline at end of file diff --git a/src/routes/extensions/+page.svelte b/src/routes/extensions/+page.svelte new file mode 100644 index 0000000..c5a99ce --- /dev/null +++ b/src/routes/extensions/+page.svelte @@ -0,0 +1 @@ +

extensions

\ No newline at end of file diff --git a/src/routes/library/+page.svelte b/src/routes/library/+page.svelte new file mode 100644 index 0000000..f245826 --- /dev/null +++ b/src/routes/library/+page.svelte @@ -0,0 +1 @@ +

library

\ No newline at end of file diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte new file mode 100644 index 0000000..b9784a9 --- /dev/null +++ b/src/routes/settings/+page.svelte @@ -0,0 +1 @@ +

settings

\ No newline at end of file diff --git a/src/routes/tracking/+page.svelte b/src/routes/tracking/+page.svelte new file mode 100644 index 0000000..9b95035 --- /dev/null +++ b/src/routes/tracking/+page.svelte @@ -0,0 +1 @@ +

tracking

\ No newline at end of file