Skip to content

Add App-Specific Favicon & PWA Install Icon Support #1957

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 2 additions & 11 deletions client/packages/lowcoder/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ class AppIndex extends React.Component<AppIndexProps, any> {
<Wrapper language={this.props.uiLanguage} fontFamily={this.props.brandingFontFamily}>
<Helmet>
{<title>{this.props.brandName}</title>}
{<link rel="icon" href={this.props.favicon} />}
{/* Favicon is set per-route (admin vs. app) */}
<meta name="description" content={this.props.brandDescription} />
<meta
name="keywords"
Expand Down Expand Up @@ -272,16 +272,7 @@ class AppIndex extends React.Component<AppIndexProps, any> {
name="apple-mobile-web-app-title"
content={this.props.brandName}
/>
<link
key="apple-touch-icon"
rel="apple-touch-icon"
href="https://raw.githubusercontent.com/lowcoder-org/lowcoder-media-assets/main/images/Lowcoder%20Logo%20512.png"
/>
<link
key="apple-touch-startup-image"
rel="apple-touch-startup-image"
href="https://raw.githubusercontent.com/lowcoder-org/lowcoder-media-assets/main/images/Lowcoder%20Logo%20512.png"
/>
{/* Apple touch icons are set per-route (admin vs. app). Removed from global scope. */}
<meta
key="application-name"
name="application-name"
Expand Down
11 changes: 11 additions & 0 deletions client/packages/lowcoder/src/pages/ApplicationV2/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ import {
UserIcon,
} from "lowcoder-design";
import React, { useCallback, useEffect, useState, useMemo } from "react";
import { Helmet } from "react-helmet";
import { favicon } from "assets/images";
import { fetchHomeData } from "redux/reduxActions/applicationActions";
import { getBrandingConfig } from "redux/selectors/configSelectors";
import { buildMaterialPreviewURL } from "util/materialUtils";
import { fetchSubscriptionsAction } from "redux/reduxActions/subscriptionActions";
import { getHomeOrg, normalAppListSelector } from "redux/selectors/applicationSelector";
import { DatasourceHome } from "../datasource";
Expand Down Expand Up @@ -90,6 +94,7 @@ export default function ApplicationHome() {
const allFolders = useSelector(foldersSelector);
const user = useSelector(getUser);
const org = useSelector(getHomeOrg);
const branding = useSelector(getBrandingConfig);
const allAppCount = allApplications.length;
const allFoldersCount = allFolders.length;
const orgHomeId = "root";
Expand Down Expand Up @@ -131,6 +136,12 @@ export default function ApplicationHome() {
return (
<DivStyled>
<LoadingBarHideTrigger />
<Helmet>
{/* Admin area default favicon; app routes will set their own */}
<link key='default-favicon' rel='icon' href={
branding?.favicon ? buildMaterialPreviewURL(branding.favicon) : favicon
} />
</Helmet>
{/* <EnterpriseProvider> */}
{/* <SimpleSubscriptionContextProvider> */}
<Layout
Expand Down
117 changes: 89 additions & 28 deletions client/packages/lowcoder/src/pages/editor/editorView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import { isEqual, noop } from "lodash";
import { AppSettingContext, AppSettingType } from "@lowcoder-ee/comps/utils/appSettingContext";
import { getBrandingSetting } from "@lowcoder-ee/redux/selectors/enterpriseSelectors";
import Flex from "antd/es/flex";
import { getAppIconPngUrl } from "util/iconConversionUtils";
// import { BottomSkeleton } from "./bottom/BottomContent";

const Header = lazy(
Expand Down Expand Up @@ -565,6 +566,28 @@ function EditorView(props: EditorViewProps) {
if (readOnly && hideHeader) {
return (
<CustomShortcutWrapper>
<Helmet>
{application && <title>{appSettingsComp?.children?.title?.getView?.() || application?.name}</title>}
{(() => {
const appId = application?.applicationId;
const themeColor = brandingSettings?.config_set?.mainBrandingColor || '#b480de';
const appIcon512 = appId ? getAppIconPngUrl(appId, 512, themeColor) : undefined;
const appIcon192 = appId ? getAppIconPngUrl(appId, 192, themeColor) : undefined;
const appFavicon = appId ? getAppIconPngUrl(appId, 192) : undefined; // No background for favicon
const manifestHref = appId ? `/api/applications/${appId}/manifest.json` : undefined;
const appTitle = appSettingsComp?.children?.title?.getView?.() || application?.name;
return [
manifestHref && <link key="app-manifest" rel="manifest" href={manifestHref} />,
appFavicon && <link key="app-favicon" rel="icon" href={appFavicon} />,
appIcon512 && <link key="apple-touch-icon" rel="apple-touch-icon" href={appIcon512} />,
appIcon512 && <link key="apple-touch-startup-image" rel="apple-touch-startup-image" href={appIcon512} />,
appIcon512 && <meta key="og:image" property="og:image" content={appIcon512} />,
appIcon512 && <meta key="twitter:image" name="twitter:image" content={appIcon512} />,
<meta key="theme-color" name="theme-color" content={themeColor} />,
appTitle && <meta key="apple-mobile-web-app-title" name="apple-mobile-web-app-title" content={String(appTitle)} />,
];
})()}
</Helmet>
{uiComp.getView()}
<div style={{ zIndex: Layers.hooksCompContainer }}>{hookCompViews}</div>
</CustomShortcutWrapper>
Expand All @@ -575,7 +598,26 @@ function EditorView(props: EditorViewProps) {
return (
<CustomShortcutWrapper>
<Helmet>
{application && <title>{appSettingsComp?.children?.title?.getView?.() || application?.name}</title>}
{application && <title>{appSettingsComp?.children?.title?.getView?.() || application?.name}</title>}
{(() => {
const appId = application?.applicationId;
const themeColor = brandingSettings?.config_set?.mainBrandingColor || '#b480de';
const appIcon512 = appId ? getAppIconPngUrl(appId, 512, themeColor) : undefined;
const appIcon192 = appId ? getAppIconPngUrl(appId, 192, themeColor) : undefined;
const appFavicon = appId ? getAppIconPngUrl(appId, 192) : undefined; // No background for favicon
const manifestHref = appId ? `/api/applications/${appId}/manifest.json` : undefined;
const appTitle = appSettingsComp?.children?.title?.getView?.() || application?.name;
return [
manifestHref && <link key="app-manifest" rel="manifest" href={manifestHref} />,
appFavicon && <link key="app-favicon" rel="icon" href={appFavicon} />,
appIcon512 && <link key="apple-touch-icon" rel="apple-touch-icon" href={appIcon512} />,
appIcon512 && <link key="apple-touch-startup-image" rel="apple-touch-startup-image" href={appIcon512} />,
appIcon512 && <meta key="og:image" property="og:image" content={appIcon512} />,
appIcon512 && <meta key="twitter:image" name="twitter:image" content={appIcon512} />,
<meta key="theme-color" name="theme-color" content={themeColor} />,
appTitle && <meta key="apple-mobile-web-app-title" name="apple-mobile-web-app-title" content={String(appTitle)} />,
];
})()}
{isLowCoderDomain || isLocalhost && [
// Adding Support for iframely to be able to embedd apps as iframes
application?.name ? ([
Expand All @@ -585,7 +627,7 @@ function EditorView(props: EditorViewProps) {
<meta key="iframely:title" property="iframely:title" content="Lowcoder 3" />,
<meta key="iframely:description" property="iframely:description" content="Lowcoder | rapid App & VideoMeeting builder for everyone." />,
]),
<link rel="iframely" type="text/html" href={window.location.href} media="(aspect-ratio: 1280/720)"/>,
<link key="iframely" rel="iframely" type="text/html" href={window.location.href} media="(aspect-ratio: 1280/720)" />,
<link key="preconnect-googleapis" rel="preconnect" href="https://fonts.googleapis.com" />,
<link key="preconnect-gstatic" rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />,
<link key="font-ubuntu" href="https://fonts.googleapis.com/css2?family=Ubuntu:ital,wght@0,300;0,400;0,700;1,400&display=swap" rel="stylesheet" />,
Expand Down Expand Up @@ -623,32 +665,51 @@ function EditorView(props: EditorViewProps) {

return (
<>
<Helmet>
{application && <title>{appSettingsComp?.children?.title?.getView?.() || application?.name}</title>}
{isLowCoderDomain || isLocalhost && [
// Adding Support for iframely to be able to embedd apps as iframes
application?.name ? ([
<meta key="iframely:title" property="iframely:title" content={application.name} />,
<meta key="iframely:description" property="iframely:description" content={application.description} />,
]) : ([
<meta key="iframely:title" property="iframely:title" content="Lowcoder 3" />,
<meta key="iframely:description" property="iframely:description" content="Lowcoder | rapid App & VideoMeeting builder for everyone." />,
]),
<link key="iframely" rel="iframely" type="text/html" href={window.location.href} media="(aspect-ratio: 1280/720)" />,
<link key="preconnect-googleapis" rel="preconnect" href="https://fonts.googleapis.com" />,
<link key="preconnect-gstatic" rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />,
<link key="font-ubuntu" href="https://fonts.googleapis.com/css2?family=Ubuntu:ital,wght@0,300;0,400;0,700;1,400&display=swap" rel="stylesheet" />,
// adding Clearbit Support for Analytics
<script key="hs-script-loader" async defer src="//js-eu1.hs-scripts.com/144574215.js" type="text/javascript" id="hs-script-loader"></script>
]}
</Helmet>
<Height100Div
onDragEnd={(e) => {
// log.debug("layout: onDragEnd. Height100Div");
editorState.setDragging(false);
draggingUtils.clearData();
} }
>
<Helmet>
{application && <title>{appSettingsComp?.children?.title?.getView?.() || application?.name}</title>}
{(() => {
const appId = application?.applicationId;
const themeColor = brandingSettings?.config_set?.mainBrandingColor || '#b480de';
const appIcon512 = appId ? getAppIconPngUrl(appId, 512, themeColor) : undefined;
const appIcon192 = appId ? getAppIconPngUrl(appId, 192, themeColor) : undefined;
const appFavicon = appId ? getAppIconPngUrl(appId, 192) : undefined; // No background for favicon
const manifestHref = appId ? `/api/applications/${appId}/manifest.json` : undefined;
const appTitle = appSettingsComp?.children?.title?.getView?.() || application?.name;
return [
manifestHref && <link key="app-manifest" rel="manifest" href={manifestHref} />,
appFavicon && <link key="app-favicon" rel="icon" href={appFavicon} />,
appIcon512 && <link key="apple-touch-icon" rel="apple-touch-icon" href={appIcon512} />,
appIcon512 && <link key="apple-touch-startup-image" rel="apple-touch-startup-image" href={appIcon512} />,
appIcon512 && <meta key="og:image" property="og:image" content={appIcon512} />,
appIcon512 && <meta key="twitter:image" name="twitter:image" content={appIcon512} />,
<meta key="theme-color" name="theme-color" content={themeColor} />,
appTitle && <meta key="apple-mobile-web-app-title" name="apple-mobile-web-app-title" content={String(appTitle)} />,
];
})()}
{isLowCoderDomain || isLocalhost && [
// Adding Support for iframely to be able to embedd apps as iframes
application?.name ? ([
<meta key="iframely:title" property="iframely:title" content={application.name} />,
<meta key="iframely:description" property="iframely:description" content={application.description} />,
]) : ([
<meta key="iframely:title" property="iframely:title" content="Lowcoder 3" />,
<meta key="iframely:description" property="iframely:description" content="Lowcoder | rapid App & VideoMeeting builder for everyone." />,
]),
<link key="iframely" rel="iframely" type="text/html" href={window.location.href} media="(aspect-ratio: 1280/720)" />,
<link key="preconnect-googleapis" rel="preconnect" href="https://fonts.googleapis.com" />,
<link key="preconnect-gstatic" rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />,
<link key="font-ubuntu" href="https://fonts.googleapis.com/css2?family=Ubuntu:ital,wght@0,300;0,400;0,700;1,400&display=swap" rel="stylesheet" />,
// adding Clearbit Support for Analytics
<script key="hs-script-loader" async defer src="//js-eu1.hs-scripts.com/144574215.js" type="text/javascript" id="hs-script-loader"></script>
]}
</Helmet>
<Height100Div
onDragEnd={(e) => {
// log.debug("layout: onDragEnd. Height100Div");
editorState.setDragging(false);
draggingUtils.clearData();
}}
>
{isPublicApp
? <PreviewHeader />
: (
Expand Down
138 changes: 138 additions & 0 deletions client/packages/lowcoder/src/util/iconConversionUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { parseIconIdentifier } from '@lowcoder-ee/comps/comps/multiIconDisplay'

/**
* Utility functions for handling app-specific favicon and icon conversion
*/

export interface AppIconInfo {
type: 'antd' | 'fontAwesome' | 'base64' | 'url' | 'unknown'
identifier: string
name?: string
url?: string
data?: string
}

/**
* Extract app icon information from app settings
*/
export function getAppIconInfo(appSettingsComp: any): AppIconInfo | null {
if (!appSettingsComp?.children?.icon?.getView) {
return null
}

const iconIdentifier = appSettingsComp.children.icon.getView()

if (!iconIdentifier) {
return null
}

// If the identifier is an object, try to extract the string value
let iconString = iconIdentifier
if (typeof iconIdentifier === 'object') {
// Check if it's a React element
if (iconIdentifier.$$typeof === Symbol.for('react.element')) {
// Try to extract icon information from React element props
if (iconIdentifier.props && iconIdentifier.props.value) {
// For URL-based icons, the value contains the URL
iconString = iconIdentifier.props.value
} else if (iconIdentifier.props && iconIdentifier.props.icon) {
iconString = iconIdentifier.props.icon
} else if (iconIdentifier.props && iconIdentifier.props.type) {
// For Ant Design icons, the type might be in props.type
iconString = iconIdentifier.props.type
} else {
return null
}
} else {
// Try to get the string value from the object
if (iconIdentifier.value !== undefined) {
iconString = iconIdentifier.value
} else if (iconIdentifier.toString) {
iconString = iconIdentifier.toString()
} else {
return null
}
}
}

const parsed = parseIconIdentifier(iconString)

return {
type: parsed.type as AppIconInfo['type'],
identifier: iconString,
name: parsed.name,
url: parsed.url,
data: parsed.data,
}
}

/**
* Generate favicon URL for an app
* This is a simple implementation that returns the icon as-is for now
* In Phase 2, this will be replaced with actual icon conversion logic
*/
export function getAppFaviconUrl(appId: string, iconInfo: AppIconInfo): string {
// Use backend PNG conversion endpoint for consistent, cacheable favicons
// The backend handles data URLs/HTTP images and falls back gracefully
return `/api/applications/${appId}/icons/192.png`
}

/**
* Check if an icon can be used as a favicon
*/
export function canUseAsFavicon(iconInfo: AppIconInfo): boolean {
switch (iconInfo.type) {
case 'url':
case 'base64':
return true
case 'antd':
case 'fontAwesome':
// These need conversion to be used as favicon
return false
default:
return false
}
}

/**
* Get the appropriate favicon for an app
* Returns the app-specific favicon if available, otherwise null
*/
export function getAppFavicon(
appSettingsComp: any,
appId: string
): string | null {
const iconInfo = getAppIconInfo(appSettingsComp)

if (!iconInfo) {
return null
}

// Always prefer the backend-rendered PNG for a reliable favicon
return getAppFaviconUrl(appId, iconInfo)
}

/**
* Build the backend PNG icon URL for a given size and optional background color.
* Pass backgroundHex with or without leading '#'.
*/
export function getAppIconPngUrl(
appId: string,
size: number,
backgroundHex?: string
): string {
const base = `/api/applications/${appId}/icons/${size}.png`
if (!backgroundHex) return base
const clean = backgroundHex.startsWith('#')
? backgroundHex
: `#${backgroundHex}`
const bg = encodeURIComponent(clean)
return `${base}?bg=${bg}`
}

/**
* Convenience URL for share previews (Open Graph / Twitter), using 512 size.
*/
export function getOgImageUrl(appId: string, backgroundHex?: string): string {
return getAppIconPngUrl(appId, 512, backgroundHex)
}
Loading
Loading