diff --git a/packages/builder/src/pages/builder/app/[application]/settings/_layout.svelte b/packages/builder/src/pages/builder/app/[application]/settings/_layout.svelte index 5691c2556b..fe360de808 100644 --- a/packages/builder/src/pages/builder/app/[application]/settings/_layout.svelte +++ b/packages/builder/src/pages/builder/app/[application]/settings/_layout.svelte @@ -29,6 +29,11 @@ url={$url("./embed")} active={$isActive("./embed")} /> + + import { + Layout, + Divider, + Heading, + Body, + Input, + TextArea, + ColorPicker, + Button, + Label, + File, + notifications, + Select, + } from "@budibase/bbui" + import { appStore } from "@/stores/builder" + import { API } from "@/api" + + // Only allow PNG files for better PWA compatibility + const imageExtensions = [".png"] + + let pwaConfig = $appStore.pwa || { + name: "", + short_name: "", + description: "", + icons: [], + background_color: "#FFFFFF", + theme_color: "#FFFFFF", + display: "standalone", + start_url: "", + scope: "", + } + + let saving = false + let iconFile = null + let iconPreview = null + + // Display mode options + const displayOptions = [ + { label: "Standalone", value: "standalone" }, + { label: "Fullscreen", value: "fullscreen" }, + { label: "Minimal UI", value: "minimal-ui" }, + { label: "Browser", value: "browser" }, + ] + + // Get existing icon if available + $: icon = + pwaConfig.icons && pwaConfig.icons.length > 0 + ? { url: pwaConfig.icons[0].src, type: "image", name: "PWA Icon" } + : null + + const previewUrl = async localFile => { + if (!localFile) { + return Promise.resolve(null) + } + + return new Promise(resolve => { + let reader = new FileReader() + try { + reader.onload = e => { + resolve({ + result: e.target.result, + }) + } + reader.readAsDataURL(localFile) + } catch (error) { + console.error(error) + resolve(null) + } + }) + } + + $: previewUrl(iconFile).then(response => { + if (response) { + iconPreview = response.result + } + }) + + async function uploadIcon(file) { + let response = {} + try { + let data = new FormData() + data.append("file", file) + response = await API.uploadBuilderAttachment(data) + } catch (error) { + notifications.error("Error uploading icon") + console.error("Error uploading icon:", error) + } + return response + } + + async function saveFiles() { + if (iconFile) { + const iconResp = await uploadIcon(iconFile) + if (iconResp[0]?.url) { + // Get the full absolute URL + let iconUrl = iconResp[0].url + // Ensure the URL is absolute + if (iconUrl.startsWith("/")) { + iconUrl = window.location.origin + iconUrl + } + + // Update the PWA config with the new icon URL + pwaConfig = { + ...pwaConfig, + icons: [ + { + src: iconUrl, + sizes: "192x192", + type: "image/png", + }, + ], + } + } + } + } + + function ensureHexFormat(color) { + if (!color) return "#FFFFFF" + + if (color.startsWith("#")) return color + + const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/) + if (rgbMatch) { + const r = parseInt(rgbMatch[1]).toString(16).padStart(2, "0") + const g = parseInt(rgbMatch[2]).toString(16).padStart(2, "0") + const b = parseInt(rgbMatch[3]).toString(16).padStart(2, "0") + return `#${r}${g}${b}`.toUpperCase() + } + + const rgbaMatch = color.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*[\d.]+\)/) + if (rgbaMatch) { + const r = parseInt(rgbaMatch[1]).toString(16).padStart(2, "0") + const g = parseInt(rgbaMatch[2]).toString(16).padStart(2, "0") + const b = parseInt(rgbaMatch[3]).toString(16).padStart(2, "0") + return `#${r}${g}${b}`.toUpperCase() + } + + return "#FFFFFF" + } + + function getCssVariableValue(cssVar) { + if (!cssVar || !cssVar.startsWith("var(--")) { + return ensureHexFormat(cssVar) + } + + const spectrumColors = { + "var(--spectrum-global-color-gray-50)": "#FFFFFF", + "var(--spectrum-global-color-gray-75)": "#FAFAFA", + "var(--spectrum-global-color-gray-100)": "#F5F5F5", + "var(--spectrum-global-color-gray-200)": "#EAEAEA", + "var(--spectrum-global-color-gray-300)": "#E1E1E1", + "var(--spectrum-global-color-gray-400)": "#CACACA", + "var(--spectrum-global-color-gray-500)": "#B3B3B3", + "var(--spectrum-global-color-gray-600)": "#8E8E8E", + "var(--spectrum-global-color-gray-700)": "#6E6E6E", + "var(--spectrum-global-color-gray-800)": "#4B4B4B", + "var(--spectrum-global-color-gray-900)": "#2C2C2C", + "var(--spectrum-global-color-blue-400)": "#2680EB", + "var(--spectrum-global-color-blue-500)": "#1473E6", + "var(--spectrum-global-color-blue-600)": "#0D66D0", + "var(--spectrum-global-color-blue-700)": "#095ABA", + "var(--spectrum-global-color-red-400)": "#E34850", + "var(--spectrum-global-color-red-500)": "#D7373F", + "var(--spectrum-global-color-red-600)": "#C9252D", + "var(--spectrum-global-color-red-700)": "#BB121A", + "var(--spectrum-global-color-green-400)": "#2D9D78", + "var(--spectrum-global-color-green-500)": "#268E6C", + "var(--spectrum-global-color-green-600)": "#12805C", + "var(--spectrum-global-color-green-700)": "#107154", + "var(--spectrum-global-color-orange-400)": "#E68619", + "var(--spectrum-global-color-orange-500)": "#DA7B11", + "var(--spectrum-global-color-orange-600)": "#CB6F10", + "var(--spectrum-global-color-orange-700)": "#BD640D", + "var(--spectrum-global-color-yellow-400)": "#DFBF00", + "var(--spectrum-global-color-yellow-500)": "#D2B200", + "var(--spectrum-global-color-yellow-600)": "#C4A600", + "var(--spectrum-global-color-yellow-700)": "#B79900", + "var(--spectrum-global-color-seafoam-400)": "#1B959A", + "var(--spectrum-global-color-seafoam-500)": "#16878C", + "var(--spectrum-global-color-seafoam-600)": "#0F797D", + "var(--spectrum-global-color-seafoam-700)": "#096C6F", + "var(--spectrum-global-color-indigo-400)": "#6767EC", + "var(--spectrum-global-color-indigo-500)": "#5C5CE0", + "var(--spectrum-global-color-indigo-600)": "#5151D3", + "var(--spectrum-global-color-indigo-700)": "#4646C6", + "var(--spectrum-global-color-magenta-400)": "#D83790", + "var(--spectrum-global-color-magenta-500)": "#CE2783", + "var(--spectrum-global-color-magenta-600)": "#BC1C74", + "var(--spectrum-global-color-magenta-700)": "#AE0E66", + "var(--spectrum-global-color-static-white)": "#FFFFFF", + "var(--spectrum-global-color-static-black)": "#000000", + } + + try { + if (spectrumColors[cssVar]) { + return spectrumColors[cssVar] + } + + const varName = cssVar.match(/var\((.*?)\)/)[1] + const computedValue = getComputedStyle(document.documentElement) + .getPropertyValue(varName) + .trim() + + if (computedValue) { + return ensureHexFormat(computedValue) + } + + return "#FFFFFF" + } catch (error) { + console.error("Error converting CSS variable:", error) + return "#FFFFFF" + } + } + + const handleSubmit = async () => { + try { + saving = true + + await saveFiles() + + const bgColor = getCssVariableValue(pwaConfig.background_color) + const themeColor = getCssVariableValue(pwaConfig.theme_color) + + const pwaConfigToSave = { + ...pwaConfig, + background_color: bgColor, + theme_color: themeColor, + } + + await API.saveAppMetadata($appStore.appId, { pwa: pwaConfigToSave }) + + appStore.update(state => ({ + ...state, + pwa: pwaConfigToSave, + })) + + notifications.success("PWA settings saved successfully") + } catch (error) { + notifications.error("Error saving PWA settings") + console.error("Error saving PWA settings:", error) + } finally { + saving = false + } + } + + + + + Progressive Web App + + Transform your app into an installable, app-like experience with a + Progressive Web App (PWA). Developers can configure app details, visuals, + and notifications to create a branded, professional experience for their + users. + + + + +
+
+ +
+ App details + + Define the identity of your app, including its name, description, and + how it will appear to users when installed. + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ Appearance + + Make your app visually appealing with a custom icon and theme. These + settings control how your app appears on splash screens and device + interfaces. + +
+ +
+ +
+ { + notifications.warn("File too large. 20mb limit") + }} + extensions={imageExtensions} + previewUrl={iconPreview || icon?.url} + on:change={e => { + if (e.detail) { + iconFile = e.detail + iconPreview = null + } else { + iconFile = null + iconPreview = null + pwaConfig = { + ...pwaConfig, + icons: [], + } + } + }} + value={iconFile || icon} + disabled={saving} + allowClear={true} + /> +
+

Use a 192x192 PNG image

+
+
+
+ +
+ + (pwaConfig.background_color = e.detail)} + /> +
+ +
+ + (pwaConfig.theme_color = e.detail)} + /> +
+ + + +
+ Manifest settings + + The manifest settings control how your app behaves once installed. + These settings define the app's entry point, navigation boundaries, + and how it appears on the user's device. Configuring these fields + ensures your app is treated as a native-like application. + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+ + diff --git a/packages/builder/src/stores/builder/app.ts b/packages/builder/src/stores/builder/app.ts index abebb5d9f1..1bf9438bda 100644 --- a/packages/builder/src/stores/builder/app.ts +++ b/packages/builder/src/stores/builder/app.ts @@ -6,6 +6,7 @@ import { AppIcon, AutomationSettings, Plugin, + PWAManifest, } from "@budibase/types" interface ClientFeatures { @@ -46,6 +47,7 @@ interface AppMetaState { revertableVersion?: string upgradableVersion?: string icon?: AppIcon + pwa?: PWAManifest } export const INITIAL_APP_META_STATE: AppMetaState = { @@ -79,6 +81,14 @@ export const INITIAL_APP_META_STATE: AppMetaState = { usedPlugins: [], automations: {}, routes: {}, + pwa: { + name: "", + short_name: "", + description: "", + icons: [], + background_color: "", + theme_color: "", + }, } export class AppMetaStore extends BudiStore { @@ -96,7 +106,6 @@ export class AppMetaStore extends BudiStore { hasLock: boolean }) { const { application: app, clientLibPath, hasLock } = pkg - this.update(state => ({ ...state, name: app.name, @@ -118,6 +127,7 @@ export class AppMetaStore extends BudiStore { initialised: true, automations: app.automations || {}, hasAppPackage: true, + pwa: app.pwa, })) } @@ -149,11 +159,21 @@ export class AppMetaStore extends BudiStore { // Returned from socket syncMetadata(metadata: { name: string; url: string; icon?: AppIcon }) { const { name, url, icon } = metadata + console.log(name) this.update(state => ({ ...state, name, url, icon, + pwa: { + ...state.pwa, + name, + short_name: state.pwa?.short_name || "", + description: state.pwa?.description || "", + icons: state.pwa?.icons || [], + background_color: state.pwa?.background_color || "", + theme_color: state.pwa?.theme_color || "", + }, })) } } diff --git a/packages/builder/src/stores/builder/websocket.ts b/packages/builder/src/stores/builder/websocket.ts index a56fec2227..b79a6fd336 100644 --- a/packages/builder/src/stores/builder/websocket.ts +++ b/packages/builder/src/stores/builder/websocket.ts @@ -101,6 +101,7 @@ export const createBuilderWebsocket = (appId: string) => { socket.onOther( BuilderSocketEvent.AppMetadataChange, ({ metadata }: { metadata: any }) => { + console.log("hello?") appStore.syncMetadata(metadata) themeStore.syncMetadata(metadata) navigationStore.syncMetadata(metadata) diff --git a/packages/server/src/api/controllers/static/index.ts b/packages/server/src/api/controllers/static/index.ts index 6b8ecda0d9..b8e78cedec 100644 --- a/packages/server/src/api/controllers/static/index.ts +++ b/packages/server/src/api/controllers/static/index.ts @@ -32,6 +32,7 @@ import { GetSignedUploadUrlRequest, GetSignedUploadUrlResponse, ProcessAttachmentResponse, + PWAManifest, ServeAppResponse, ServeBuilderPreviewResponse, ServeClientLibraryResponse, @@ -189,8 +190,9 @@ export const serveApp = async function (ctx: UserCtx) { const sideNav = appInfo.navigation.navigation === "Left" const hideFooter = ctx?.user?.license?.features?.includes(Feature.BRANDING) || false - const themeVariables = getThemeVariables(appInfo?.theme) - + const themeVariables = getThemeVariables(appInfo?.theme || {}) + const hasPWA = Object.keys(appInfo.pwa || {}).length > 0 + const manifestUrl = hasPWA ? `/api/apps/${appId}/manifest.json` : "" if (!env.isJest()) { const plugins = await objectStore.enrichPluginURLs(appInfo.usedPlugins) /* @@ -223,8 +225,25 @@ export const serveApp = async function (ctx: UserCtx) { const { head, html, css } = AppComponent.render({ props }) const appHbs = loadHandlebarsFile(appHbsPath) + + let extraHead = "" + if (hasPWA) { + extraHead = `` + + extraHead += ` + +` + if (appInfo.pwa.icons && appInfo.pwa.icons.length > 0) { + let appleIconUrl = appInfo.pwa.icons[0].src + console.log(appleIconUrl) + extraHead += `` + } + } + ctx.body = await processString(appHbs, { - head, + head: `${head}${extraHead}`, body: html, css: `:root{${themeVariables}} ${css.code}`, appId, @@ -365,3 +384,59 @@ export const getSignedUploadURL = async function ( ctx.body = { signedUrl, publicUrl } } + +export const serveManifest = async function (ctx: UserCtx) { + const appId = context.getAppId() + if (!appId) { + ctx.status = 404 + ctx.body = { message: "App not found" } + return + } + + try { + const db = context.getAppDB({ skip_setup: true }) + const appInfo = await db.get(DocumentType.APP_METADATA) + + if (!appInfo.pwa) { + ctx.status = 404 + ctx.body = { message: "PWA not configured for this app" } + return + } + + const manifest: PWAManifest = { + name: appInfo.pwa.name || appInfo.name, + short_name: appInfo.pwa.short_name || appInfo.name, + description: appInfo.pwa.description || "", + start_url: + `/app${appInfo.url}#${appInfo.pwa.start_url}` || `/app/${appInfo.url}`, + display: appInfo.pwa.display || "standalone", + background_color: appInfo.pwa.background_color || "#FFFFFF", + theme_color: appInfo.pwa.theme_color || "#FFFFFF", + icons: [], + } + + if (appInfo.pwa.icons && appInfo.pwa.icons.length > 0) { + manifest.icons = appInfo.pwa.icons.map(icon => { + let src = icon.src + + if (src && src.startsWith("/") && !src.startsWith("//")) { + const origin = ctx.request.origin + src = `${origin}${src}` + } + + return { + ...icon, + src, + type: icon.type || "image/png", + } + }) + } + + ctx.set("Content-Type", "application/json") + ctx.body = manifest + } catch (error) { + console.error("Error serving manifest:", error) + ctx.status = 500 + ctx.body = { message: "Error generating manifest" } + } +} diff --git a/packages/server/src/api/routes/static.ts b/packages/server/src/api/routes/static.ts index f331609923..4fbd96249a 100644 --- a/packages/server/src/api/routes/static.ts +++ b/packages/server/src/api/routes/static.ts @@ -31,6 +31,7 @@ router.param("file", async (file: any, ctx: any, next: any) => { router .get("/builder/:file*", controller.serveBuilder) .get("/api/assets/client", controller.serveClientLibrary) + .get("/api/apps/:appId/manifest.json", controller.serveManifest) .post("/api/attachments/process", authorized(BUILDER), controller.uploadFile) .post("/api/beta/:feature", controller.toggleBetaUiFeature) .post( diff --git a/packages/types/src/documents/app/app.ts b/packages/types/src/documents/app/app.ts index 46799cb61a..02bc4a915e 100644 --- a/packages/types/src/documents/app/app.ts +++ b/packages/types/src/documents/app/app.ts @@ -29,6 +29,7 @@ export interface App extends Document { snippets?: Snippet[] creationVersion?: string updatedBy?: string + pwa?: PWAManifest } export interface AppInstance { @@ -82,3 +83,15 @@ export interface AppFeatures { export interface AutomationSettings { chainAutomations?: boolean } + +export interface PWAManifest { + name: string + short_name: string + description: string + icons: { src: string; sizes: string; type: string }[] + background_color: string + theme_color: string + display?: string + start_url?: string + scope?: string +}