Skip to content

Commit 8df408a

Browse files
author
Elier Herrera
committed
[feat]: Implement per-app PWA icons and favicons support, including backend endpoints for icon retrieval and manifest generation.
1 parent 1671585 commit 8df408a

File tree

10 files changed

+701
-40
lines changed

10 files changed

+701
-40
lines changed

client/packages/lowcoder/src/app.tsx

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ class AppIndex extends React.Component<AppIndexProps, any> {
197197
<Wrapper language={this.props.uiLanguage} fontFamily={this.props.brandingFontFamily}>
198198
<Helmet>
199199
{<title>{this.props.brandName}</title>}
200-
{<link rel="icon" href={this.props.favicon} />}
200+
{/* Favicon is set per-route (admin vs. app) */}
201201
<meta name="description" content={this.props.brandDescription} />
202202
<meta
203203
name="keywords"
@@ -272,16 +272,7 @@ class AppIndex extends React.Component<AppIndexProps, any> {
272272
name="apple-mobile-web-app-title"
273273
content={this.props.brandName}
274274
/>
275-
<link
276-
key="apple-touch-icon"
277-
rel="apple-touch-icon"
278-
href="https://raw.githubusercontent.com/lowcoder-org/lowcoder-media-assets/main/images/Lowcoder%20Logo%20512.png"
279-
/>
280-
<link
281-
key="apple-touch-startup-image"
282-
rel="apple-touch-startup-image"
283-
href="https://raw.githubusercontent.com/lowcoder-org/lowcoder-media-assets/main/images/Lowcoder%20Logo%20512.png"
284-
/>
275+
{/* Apple touch icons are set per-route (admin vs. app). Removed from global scope. */}
285276
<meta
286277
key="application-name"
287278
name="application-name"

client/packages/lowcoder/src/pages/ApplicationV2/index.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ import {
3232
UserIcon,
3333
} from "lowcoder-design";
3434
import React, { useCallback, useEffect, useState, useMemo } from "react";
35+
import { Helmet } from "react-helmet";
36+
import { favicon } from "assets/images";
3537
import { fetchHomeData } from "redux/reduxActions/applicationActions";
38+
import { getBrandingConfig } from "redux/selectors/configSelectors";
39+
import { buildMaterialPreviewURL } from "util/materialUtils";
3640
import { fetchSubscriptionsAction } from "redux/reduxActions/subscriptionActions";
3741
import { getHomeOrg, normalAppListSelector } from "redux/selectors/applicationSelector";
3842
import { DatasourceHome } from "../datasource";
@@ -90,6 +94,7 @@ export default function ApplicationHome() {
9094
const allFolders = useSelector(foldersSelector);
9195
const user = useSelector(getUser);
9296
const org = useSelector(getHomeOrg);
97+
const branding = useSelector(getBrandingConfig);
9398
const allAppCount = allApplications.length;
9499
const allFoldersCount = allFolders.length;
95100
const orgHomeId = "root";
@@ -131,6 +136,12 @@ export default function ApplicationHome() {
131136
return (
132137
<DivStyled>
133138
<LoadingBarHideTrigger />
139+
<Helmet>
140+
{/* Admin area default favicon; app routes will set their own */}
141+
<link key='default-favicon' rel='icon' href={
142+
branding?.favicon ? buildMaterialPreviewURL(branding.favicon) : favicon
143+
} />
144+
</Helmet>
134145
{/* <EnterpriseProvider> */}
135146
{/* <SimpleSubscriptionContextProvider> */}
136147
<Layout

client/packages/lowcoder/src/pages/editor/editorView.tsx

Lines changed: 89 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import { isEqual, noop } from "lodash";
6464
import { AppSettingContext, AppSettingType } from "@lowcoder-ee/comps/utils/appSettingContext";
6565
import { getBrandingSetting } from "@lowcoder-ee/redux/selectors/enterpriseSelectors";
6666
import Flex from "antd/es/flex";
67+
import { getAppIconPngUrl } from "util/iconConversionUtils";
6768
// import { BottomSkeleton } from "./bottom/BottomContent";
6869

6970
const Header = lazy(
@@ -565,6 +566,28 @@ function EditorView(props: EditorViewProps) {
565566
if (readOnly && hideHeader) {
566567
return (
567568
<CustomShortcutWrapper>
569+
<Helmet>
570+
{application && <title>{appSettingsComp?.children?.title?.getView?.() || application?.name}</title>}
571+
{(() => {
572+
const appId = application?.applicationId;
573+
const themeColor = brandingSettings?.config_set?.mainBrandingColor || '#b480de';
574+
const appIcon512 = appId ? getAppIconPngUrl(appId, 512, themeColor) : undefined;
575+
const appIcon192 = appId ? getAppIconPngUrl(appId, 192, themeColor) : undefined;
576+
const appFavicon = appId ? getAppIconPngUrl(appId, 192) : undefined; // No background for favicon
577+
const manifestHref = appId ? `/api/applications/${appId}/manifest.json` : undefined;
578+
const appTitle = appSettingsComp?.children?.title?.getView?.() || application?.name;
579+
return [
580+
manifestHref && <link key="app-manifest" rel="manifest" href={manifestHref} />,
581+
appFavicon && <link key="app-favicon" rel="icon" href={appFavicon} />,
582+
appIcon512 && <link key="apple-touch-icon" rel="apple-touch-icon" href={appIcon512} />,
583+
appIcon512 && <link key="apple-touch-startup-image" rel="apple-touch-startup-image" href={appIcon512} />,
584+
appIcon512 && <meta key="og:image" property="og:image" content={appIcon512} />,
585+
appIcon512 && <meta key="twitter:image" name="twitter:image" content={appIcon512} />,
586+
<meta key="theme-color" name="theme-color" content={themeColor} />,
587+
appTitle && <meta key="apple-mobile-web-app-title" name="apple-mobile-web-app-title" content={String(appTitle)} />,
588+
];
589+
})()}
590+
</Helmet>
568591
{uiComp.getView()}
569592
<div style={{ zIndex: Layers.hooksCompContainer }}>{hookCompViews}</div>
570593
</CustomShortcutWrapper>
@@ -575,7 +598,26 @@ function EditorView(props: EditorViewProps) {
575598
return (
576599
<CustomShortcutWrapper>
577600
<Helmet>
578-
{application && <title>{appSettingsComp?.children?.title?.getView?.() || application?.name}</title>}
601+
{application && <title>{appSettingsComp?.children?.title?.getView?.() || application?.name}</title>}
602+
{(() => {
603+
const appId = application?.applicationId;
604+
const themeColor = brandingSettings?.config_set?.mainBrandingColor || '#b480de';
605+
const appIcon512 = appId ? getAppIconPngUrl(appId, 512, themeColor) : undefined;
606+
const appIcon192 = appId ? getAppIconPngUrl(appId, 192, themeColor) : undefined;
607+
const appFavicon = appId ? getAppIconPngUrl(appId, 192) : undefined; // No background for favicon
608+
const manifestHref = appId ? `/api/applications/${appId}/manifest.json` : undefined;
609+
const appTitle = appSettingsComp?.children?.title?.getView?.() || application?.name;
610+
return [
611+
manifestHref && <link key="app-manifest" rel="manifest" href={manifestHref} />,
612+
appFavicon && <link key="app-favicon" rel="icon" href={appFavicon} />,
613+
appIcon512 && <link key="apple-touch-icon" rel="apple-touch-icon" href={appIcon512} />,
614+
appIcon512 && <link key="apple-touch-startup-image" rel="apple-touch-startup-image" href={appIcon512} />,
615+
appIcon512 && <meta key="og:image" property="og:image" content={appIcon512} />,
616+
appIcon512 && <meta key="twitter:image" name="twitter:image" content={appIcon512} />,
617+
<meta key="theme-color" name="theme-color" content={themeColor} />,
618+
appTitle && <meta key="apple-mobile-web-app-title" name="apple-mobile-web-app-title" content={String(appTitle)} />,
619+
];
620+
})()}
579621
{isLowCoderDomain || isLocalhost && [
580622
// Adding Support for iframely to be able to embedd apps as iframes
581623
application?.name ? ([
@@ -585,7 +627,7 @@ function EditorView(props: EditorViewProps) {
585627
<meta key="iframely:title" property="iframely:title" content="Lowcoder 3" />,
586628
<meta key="iframely:description" property="iframely:description" content="Lowcoder | rapid App & VideoMeeting builder for everyone." />,
587629
]),
588-
<link rel="iframely" type="text/html" href={window.location.href} media="(aspect-ratio: 1280/720)"/>,
630+
<link key="iframely" rel="iframely" type="text/html" href={window.location.href} media="(aspect-ratio: 1280/720)" />,
589631
<link key="preconnect-googleapis" rel="preconnect" href="https://fonts.googleapis.com" />,
590632
<link key="preconnect-gstatic" rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />,
591633
<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" />,
@@ -623,32 +665,51 @@ function EditorView(props: EditorViewProps) {
623665

624666
return (
625667
<>
626-
<Helmet>
627-
{application && <title>{appSettingsComp?.children?.title?.getView?.() || application?.name}</title>}
628-
{isLowCoderDomain || isLocalhost && [
629-
// Adding Support for iframely to be able to embedd apps as iframes
630-
application?.name ? ([
631-
<meta key="iframely:title" property="iframely:title" content={application.name} />,
632-
<meta key="iframely:description" property="iframely:description" content={application.description} />,
633-
]) : ([
634-
<meta key="iframely:title" property="iframely:title" content="Lowcoder 3" />,
635-
<meta key="iframely:description" property="iframely:description" content="Lowcoder | rapid App & VideoMeeting builder for everyone." />,
636-
]),
637-
<link key="iframely" rel="iframely" type="text/html" href={window.location.href} media="(aspect-ratio: 1280/720)" />,
638-
<link key="preconnect-googleapis" rel="preconnect" href="https://fonts.googleapis.com" />,
639-
<link key="preconnect-gstatic" rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />,
640-
<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" />,
641-
// adding Clearbit Support for Analytics
642-
<script key="hs-script-loader" async defer src="//js-eu1.hs-scripts.com/144574215.js" type="text/javascript" id="hs-script-loader"></script>
643-
]}
644-
</Helmet>
645-
<Height100Div
646-
onDragEnd={(e) => {
647-
// log.debug("layout: onDragEnd. Height100Div");
648-
editorState.setDragging(false);
649-
draggingUtils.clearData();
650-
} }
651-
>
668+
<Helmet>
669+
{application && <title>{appSettingsComp?.children?.title?.getView?.() || application?.name}</title>}
670+
{(() => {
671+
const appId = application?.applicationId;
672+
const themeColor = brandingSettings?.config_set?.mainBrandingColor || '#b480de';
673+
const appIcon512 = appId ? getAppIconPngUrl(appId, 512, themeColor) : undefined;
674+
const appIcon192 = appId ? getAppIconPngUrl(appId, 192, themeColor) : undefined;
675+
const appFavicon = appId ? getAppIconPngUrl(appId, 192) : undefined; // No background for favicon
676+
const manifestHref = appId ? `/api/applications/${appId}/manifest.json` : undefined;
677+
const appTitle = appSettingsComp?.children?.title?.getView?.() || application?.name;
678+
return [
679+
manifestHref && <link key="app-manifest" rel="manifest" href={manifestHref} />,
680+
appFavicon && <link key="app-favicon" rel="icon" href={appFavicon} />,
681+
appIcon512 && <link key="apple-touch-icon" rel="apple-touch-icon" href={appIcon512} />,
682+
appIcon512 && <link key="apple-touch-startup-image" rel="apple-touch-startup-image" href={appIcon512} />,
683+
appIcon512 && <meta key="og:image" property="og:image" content={appIcon512} />,
684+
appIcon512 && <meta key="twitter:image" name="twitter:image" content={appIcon512} />,
685+
<meta key="theme-color" name="theme-color" content={themeColor} />,
686+
appTitle && <meta key="apple-mobile-web-app-title" name="apple-mobile-web-app-title" content={String(appTitle)} />,
687+
];
688+
})()}
689+
{isLowCoderDomain || isLocalhost && [
690+
// Adding Support for iframely to be able to embedd apps as iframes
691+
application?.name ? ([
692+
<meta key="iframely:title" property="iframely:title" content={application.name} />,
693+
<meta key="iframely:description" property="iframely:description" content={application.description} />,
694+
]) : ([
695+
<meta key="iframely:title" property="iframely:title" content="Lowcoder 3" />,
696+
<meta key="iframely:description" property="iframely:description" content="Lowcoder | rapid App & VideoMeeting builder for everyone." />,
697+
]),
698+
<link key="iframely" rel="iframely" type="text/html" href={window.location.href} media="(aspect-ratio: 1280/720)" />,
699+
<link key="preconnect-googleapis" rel="preconnect" href="https://fonts.googleapis.com" />,
700+
<link key="preconnect-gstatic" rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />,
701+
<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" />,
702+
// adding Clearbit Support for Analytics
703+
<script key="hs-script-loader" async defer src="//js-eu1.hs-scripts.com/144574215.js" type="text/javascript" id="hs-script-loader"></script>
704+
]}
705+
</Helmet>
706+
<Height100Div
707+
onDragEnd={(e) => {
708+
// log.debug("layout: onDragEnd. Height100Div");
709+
editorState.setDragging(false);
710+
draggingUtils.clearData();
711+
}}
712+
>
652713
{isPublicApp
653714
? <PreviewHeader />
654715
: (
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { parseIconIdentifier } from '@lowcoder-ee/comps/comps/multiIconDisplay'
2+
3+
/**
4+
* Utility functions for handling app-specific favicon and icon conversion
5+
*/
6+
7+
export interface AppIconInfo {
8+
type: 'antd' | 'fontAwesome' | 'base64' | 'url' | 'unknown'
9+
identifier: string
10+
name?: string
11+
url?: string
12+
data?: string
13+
}
14+
15+
/**
16+
* Extract app icon information from app settings
17+
*/
18+
export function getAppIconInfo(appSettingsComp: any): AppIconInfo | null {
19+
if (!appSettingsComp?.children?.icon?.getView) {
20+
return null
21+
}
22+
23+
const iconIdentifier = appSettingsComp.children.icon.getView()
24+
25+
if (!iconIdentifier) {
26+
return null
27+
}
28+
29+
// If the identifier is an object, try to extract the string value
30+
let iconString = iconIdentifier
31+
if (typeof iconIdentifier === 'object') {
32+
// Check if it's a React element
33+
if (iconIdentifier.$$typeof === Symbol.for('react.element')) {
34+
// Try to extract icon information from React element props
35+
if (iconIdentifier.props && iconIdentifier.props.value) {
36+
// For URL-based icons, the value contains the URL
37+
iconString = iconIdentifier.props.value
38+
} else if (iconIdentifier.props && iconIdentifier.props.icon) {
39+
iconString = iconIdentifier.props.icon
40+
} else if (iconIdentifier.props && iconIdentifier.props.type) {
41+
// For Ant Design icons, the type might be in props.type
42+
iconString = iconIdentifier.props.type
43+
} else {
44+
return null
45+
}
46+
} else {
47+
// Try to get the string value from the object
48+
if (iconIdentifier.value !== undefined) {
49+
iconString = iconIdentifier.value
50+
} else if (iconIdentifier.toString) {
51+
iconString = iconIdentifier.toString()
52+
} else {
53+
return null
54+
}
55+
}
56+
}
57+
58+
const parsed = parseIconIdentifier(iconString)
59+
60+
return {
61+
type: parsed.type as AppIconInfo['type'],
62+
identifier: iconString,
63+
name: parsed.name,
64+
url: parsed.url,
65+
data: parsed.data,
66+
}
67+
}
68+
69+
/**
70+
* Generate favicon URL for an app
71+
* This is a simple implementation that returns the icon as-is for now
72+
* In Phase 2, this will be replaced with actual icon conversion logic
73+
*/
74+
export function getAppFaviconUrl(appId: string, iconInfo: AppIconInfo): string {
75+
// Use backend PNG conversion endpoint for consistent, cacheable favicons
76+
// The backend handles data URLs/HTTP images and falls back gracefully
77+
return `/api/applications/${appId}/icons/192.png`
78+
}
79+
80+
/**
81+
* Check if an icon can be used as a favicon
82+
*/
83+
export function canUseAsFavicon(iconInfo: AppIconInfo): boolean {
84+
switch (iconInfo.type) {
85+
case 'url':
86+
case 'base64':
87+
return true
88+
case 'antd':
89+
case 'fontAwesome':
90+
// These need conversion to be used as favicon
91+
return false
92+
default:
93+
return false
94+
}
95+
}
96+
97+
/**
98+
* Get the appropriate favicon for an app
99+
* Returns the app-specific favicon if available, otherwise null
100+
*/
101+
export function getAppFavicon(
102+
appSettingsComp: any,
103+
appId: string
104+
): string | null {
105+
const iconInfo = getAppIconInfo(appSettingsComp)
106+
107+
if (!iconInfo) {
108+
return null
109+
}
110+
111+
// Always prefer the backend-rendered PNG for a reliable favicon
112+
return getAppFaviconUrl(appId, iconInfo)
113+
}
114+
115+
/**
116+
* Build the backend PNG icon URL for a given size and optional background color.
117+
* Pass backgroundHex with or without leading '#'.
118+
*/
119+
export function getAppIconPngUrl(
120+
appId: string,
121+
size: number,
122+
backgroundHex?: string
123+
): string {
124+
const base = `/api/applications/${appId}/icons/${size}.png`
125+
if (!backgroundHex) return base
126+
const clean = backgroundHex.startsWith('#')
127+
? backgroundHex
128+
: `#${backgroundHex}`
129+
const bg = encodeURIComponent(clean)
130+
return `${base}?bg=${bg}`
131+
}
132+
133+
/**
134+
* Convenience URL for share previews (Open Graph / Twitter), using 512 size.
135+
*/
136+
export function getOgImageUrl(appId: string, backgroundHex?: string): string {
137+
return getAppIconPngUrl(appId, 512, backgroundHex)
138+
}

0 commit comments

Comments
 (0)