Admin Panel/Settings/Layout, for PWA: Custom head meta, link, icons, assetlinks.json, site.webmanifest.

Thanks to xet7 !
This commit is contained in:
Lauri Ojansivu 2026-02-15 21:49:55 +02:00
parent dace6b78c0
commit b5a13f0206
16 changed files with 1016 additions and 3 deletions

View file

@ -15,6 +15,7 @@ import '/client/components/settings/migrationProgress';
// Import cron settings
import '/client/components/settings/cronSettings';
// Custom head tags
// Mirror Meteor login token into a cookie for server-side file route auth
// This enables cookie-based auth for /cdn/storage/* without leaking ROOT_URL

View file

@ -230,3 +230,22 @@ li.has-error .form-group .wekan-form-control {
border-color: #a94442;
box-shadow: inset 0 1px 1px rgba(0,0,0,0.075);
}
/* Constrain textarea elements with balanced left/right spacing */
.setting-content .content-body .main-body textarea.wekan-form-control {
width: calc(100% - 20px);
margin: 0 10px;
max-width: calc(100vw - 320px);
box-sizing: border-box;
}
/* For nested custom head/manifest/assetlinks sections */
.setting-content .content-body .main-body .custom-head-settings textarea.wekan-form-control,
.setting-content .content-body .main-body .custom-manifest-settings textarea.wekan-form-control,
.setting-content .content-body .main-body .custom-assetlinks-settings textarea.wekan-form-control {
width: calc(100% - 20px);
margin-left: 0;
margin-right: 10px;
max-width: calc(100vw - 240px);
box-sizing: border-box;
}

View file

@ -460,6 +460,41 @@ template(name='layoutSettings')
textarea#support-page-text.wekan-form-control= currentSetting.supportPageText
li
button.js-support-save.primary {{_ 'save'}}
li
a.flex.js-toggle-custom-head
.materialCheckBox(class="{{#if currentSetting.customHeadEnabled}}is-checked{{/if}}")
span {{_ 'custom-head-tags-enabled'}}
li
.custom-head-settings(class="{{#if currentSetting.customHeadEnabled}}{{else}}hide{{/if}}")
ul
li
.title {{_ 'custom-head-meta-tags'}}
textarea#custom-head-meta.wekan-form-control= customHeadMetaTagsValue
li
.title {{_ 'custom-head-link-tags'}}
textarea#custom-head-links.wekan-form-control= customHeadLinkTagsValue
li
a.flex.js-toggle-custom-manifest
.materialCheckBox(class="{{#if currentSetting.customManifestEnabled}}is-checked{{/if}}")
span {{_ 'custom-manifest-enabled'}}
li
.custom-manifest-settings(class="{{#if currentSetting.customManifestEnabled}}{{else}}hide{{/if}}")
.title {{_ 'custom-head-manifest-content'}}
textarea#custom-manifest-content.wekan-form-control= customManifestContentValue
li
button.js-custom-head-save.primary {{_ 'save'}}
li
a.flex.js-toggle-custom-assetlinks
.materialCheckBox(class="{{#if currentSetting.customAssetLinksEnabled}}is-checked{{/if}}")
span {{_ 'custom-assetlinks-enabled'}}
li
.custom-assetlinks-settings(class="{{#if currentSetting.customAssetLinksEnabled}}{{else}}hide{{/if}}")
ul
li
.title {{_ 'custom-assetlinks-content'}}
textarea#custom-assetlinks-content.wekan-form-control= customAssetLinksContentValue
li
button.js-custom-assetlinks-save.primary {{_ 'save'}}
li.layout-form
.title {{_ 'oidc-button-text'}}
.form-group

View file

@ -759,6 +759,182 @@ BlazeComponent.extendComponent({
this.setLoading(false);
},
toggleCustomHead() {
this.setLoading(true);
const customHeadEnabled = !$('.js-toggle-custom-head .materialCheckBox').hasClass('is-checked');
$('.js-toggle-custom-head .materialCheckBox').toggleClass('is-checked');
$('.custom-head-settings').toggleClass('hide');
Settings.update(ReactiveCache.getCurrentSetting()._id, {
$set: { customHeadEnabled },
});
this.setLoading(false);
},
toggleCustomManifest() {
this.setLoading(true);
const customManifestEnabled = !$('.js-toggle-custom-manifest .materialCheckBox').hasClass('is-checked');
$('.js-toggle-custom-manifest .materialCheckBox').toggleClass('is-checked');
$('.custom-manifest-settings').toggleClass('hide');
Settings.update(ReactiveCache.getCurrentSetting()._id, {
$set: { customManifestEnabled },
});
this.setLoading(false);
},
saveCustomHeadSettings() {
this.setLoading(true);
const customHeadMetaTags = $('#custom-head-meta').val() || '';
let customManifestContent = $('#custom-manifest-content').val() || '';
// Validate and clean JSON if present
if (customManifestContent.trim()) {
const cleanResult = this.cleanAndValidateJSON(customManifestContent);
if (cleanResult.error) {
this.setLoading(false);
alert(`Invalid manifest JSON: ${cleanResult.error}`);
return;
}
customManifestContent = cleanResult.json;
// Update the textarea with cleaned version
$('#custom-manifest-content').val(customManifestContent);
}
const customHeadLinkTags = $('#custom-head-links').val() || '';
try {
Settings.update(ReactiveCache.getCurrentSetting()._id, {
$set: {
customHeadMetaTags,
customHeadLinkTags,
customManifestContent,
},
});
} catch (e) {
return;
} finally {
this.setLoading(false);
}
},
cleanAndValidateJSON(content) {
if (!content || !content.trim()) {
return { json: content };
}
try {
// Try to parse as-is
const parsed = JSON.parse(content);
return { json: JSON.stringify(parsed, null, 2) };
} catch (e) {
const errorMsg = e.message;
// If error is "unexpected non-whitespace character after JSON data"
if (errorMsg.includes('unexpected non-whitespace character after JSON data')) {
try {
// Try to find and extract valid JSON by finding matching braces/brackets
const trimmed = content.trim();
let depth = 0;
let endPos = -1;
let inString = false;
let escapeNext = false;
for (let i = 0; i < trimmed.length; i++) {
const char = trimmed[i];
if (escapeNext) {
escapeNext = false;
continue;
}
if (char === '\\') {
escapeNext = true;
continue;
}
if (char === '"' && !escapeNext) {
inString = !inString;
continue;
}
if (inString) continue;
if (char === '{' || char === '[') {
depth++;
} else if (char === '}' || char === ']') {
depth--;
if (depth === 0) {
endPos = i + 1;
break;
}
}
}
if (endPos > 0) {
const cleanedContent = trimmed.substring(0, endPos);
const parsed = JSON.parse(cleanedContent);
return { json: JSON.stringify(parsed, null, 2) };
}
} catch (fixError) {
// If fix attempt fails, return original error
}
}
// Remove trailing commas (common error)
if (errorMsg.includes('Unexpected token')) {
try {
const fixed = content.replace(/,(\s*[}\]])/g, '$1');
const parsed = JSON.parse(fixed);
return { json: JSON.stringify(parsed, null, 2) };
} catch (fixError) {
// Continue to error return
}
}
return { error: errorMsg };
}
},
toggleCustomAssetLinks() {
this.setLoading(true);
const customAssetLinksEnabled = !$('.js-toggle-custom-assetlinks .materialCheckBox').hasClass('is-checked');
$('.js-toggle-custom-assetlinks .materialCheckBox').toggleClass('is-checked');
$('.custom-assetlinks-settings').toggleClass('hide');
Settings.update(ReactiveCache.getCurrentSetting()._id, {
$set: { customAssetLinksEnabled },
});
this.setLoading(false);
},
saveCustomAssetLinksSettings() {
this.setLoading(true);
let customAssetLinksContent = $('#custom-assetlinks-content').val() || '';
// Validate and clean JSON if present
if (customAssetLinksContent.trim()) {
const cleanResult = this.cleanAndValidateJSON(customAssetLinksContent);
if (cleanResult.error) {
this.setLoading(false);
alert(`Invalid assetlinks JSON: ${cleanResult.error}`);
return;
}
customAssetLinksContent = cleanResult.json;
// Update the textarea with cleaned version
$('#custom-assetlinks-content').val(customAssetLinksContent);
}
try {
Settings.update(ReactiveCache.getCurrentSetting()._id, {
$set: {
customAssetLinksContent,
},
});
} catch (e) {
return;
} finally {
this.setLoading(false);
}
},
saveSupportSettings() {
this.setLoading(true);
const supportTitle = ($('#support-title').val() || '').trim();
@ -808,6 +984,11 @@ BlazeComponent.extendComponent({
'click a.js-toggle-support': this.toggleSupportPage,
'click a.js-toggle-support-public': this.toggleSupportPublic,
'click button.js-support-save': this.saveSupportSettings,
'click a.js-toggle-custom-head': this.toggleCustomHead,
'click a.js-toggle-custom-manifest': this.toggleCustomManifest,
'click button.js-custom-head-save': this.saveCustomHeadSettings,
'click a.js-toggle-custom-assetlinks': this.toggleCustomAssetLinks,
'click button.js-custom-assetlinks-save': this.saveCustomAssetLinksSettings,
'click a.js-toggle-display-authentication-method': this
.toggleDisplayAuthenticationMethod,
},

View file

@ -1,4 +1,10 @@
import { ReactiveCache } from '/imports/reactiveCache';
import {
DEFAULT_ASSETLINKS,
DEFAULT_HEAD_LINKS,
DEFAULT_HEAD_META,
DEFAULT_SITE_MANIFEST,
} from '/imports/lib/customHeadDefaults';
import { Blaze } from 'meteor/blaze';
import { Session } from 'meteor/session';
import {
@ -42,6 +48,38 @@ Blaze.registerHelper('currentSetting', () => {
return ret;
});
Blaze.registerHelper('customHeadMetaTagsValue', () => {
const setting = ReactiveCache.getCurrentSetting();
if (setting && typeof setting.customHeadMetaTags === 'string') {
return setting.customHeadMetaTags;
}
return DEFAULT_HEAD_META;
});
Blaze.registerHelper('customHeadLinkTagsValue', () => {
const setting = ReactiveCache.getCurrentSetting();
if (setting && typeof setting.customHeadLinkTags === 'string') {
return setting.customHeadLinkTags;
}
return DEFAULT_HEAD_LINKS;
});
Blaze.registerHelper('customManifestContentValue', () => {
const setting = ReactiveCache.getCurrentSetting();
if (setting && typeof setting.customManifestContent === 'string') {
return setting.customManifestContent;
}
return DEFAULT_SITE_MANIFEST;
});
Blaze.registerHelper('customAssetLinksContentValue', () => {
const setting = ReactiveCache.getCurrentSetting();
if (setting && typeof setting.customAssetLinksContent === 'string') {
return setting.customAssetLinksContent;
}
return DEFAULT_ASSETLINKS;
});
Blaze.registerHelper('currentUser', () => {
const ret = ReactiveCache.getCurrentUser();
return ret;

View file

@ -758,11 +758,12 @@ Utils = {
},
setCustomUI(data) {
const productName = (data && data.productName) ? data.productName : 'Wekan';
const currentBoard = Utils.getCurrentBoard();
if (currentBoard) {
document.title = `${currentBoard.title} - ${data.productName}`;
document.title = `${currentBoard.title} - ${productName}`;
} else {
document.title = `${data.productName}`;
document.title = productName;
}
},

View file

@ -298,7 +298,11 @@ FlowRouter.route('/global-search', {
Utils.manageCustomUI();
Utils.manageMatomo();
document.title = TAPi18n.__('globalSearch-title');
// Set title with product name
const settings = Settings.findOne({});
const productName = (settings && settings.productName) ? settings.productName : 'Wekan';
document.title = `${TAPi18n.__('globalSearch-title')} - ${productName}`;
if (FlowRouter.getQueryParam('q')) {
Session.set(

View file

@ -942,6 +942,13 @@
"authentication-method": "Authentication method",
"authentication-type": "Authentication type",
"custom-product-name": "Custom Product Name",
"custom-head-tags-enabled": "Enable custom head tags",
"custom-head-meta-tags": "Custom meta tags (HTML)",
"custom-head-link-tags": "Custom link tags (HTML)",
"custom-manifest-enabled": "Enable custom web manifest",
"custom-head-manifest-content": "Custom web manifest content (JSON)",
"custom-assetlinks-enabled": "Enable custom assetlinks.json",
"custom-assetlinks-content": "Custom assetlinks.json content (JSON)",
"layout": "Layout",
"hide-logo": "Hide Logo",
"hide-card-counter-list": "Hide card counter list on All Boards",

View file

@ -0,0 +1,553 @@
export const DEFAULT_HEAD_META = `<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, user-scalable=yes, viewport-fit=cover">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Wekan">
<meta name="application-name" content="Wekan">
<meta name="msapplication-TileColor" content="#00aba9">
<meta name="theme-color" content="#ffffff">`;
export const DEFAULT_HEAD_LINKS = `<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" crossorigin="use-credentials" href="/site.webmanifest">
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">`;
export const DEFAULT_SITE_MANIFEST = `{
"name": "Wekan",
"short_name": "Wekan",
"icons": [
{
"src": "svg-etc/wekan-logo-512.svg",
"sizes": "any",
"type": "image/svg"
},
{
"src": "android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "Square150x150Logo.scale-100.png",
"sizes": "150x150",
"type": "image/png"
},
{
"src": "Square44x44Logo.scale-100.png",
"sizes": "44x44",
"type": "image/png"
},
{
"src": "StoreLogo.scale-100.png",
"sizes": "50x50",
"type": "image/png"
},
{
"src": "maskable_icon.png",
"sizes": "474x474",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "monochrome-icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "monochrome"
},
{
"src": "windows11/SmallTile.scale-100.png",
"sizes": "71x71"
},
{
"src": "windows11/SmallTile.scale-125.png",
"sizes": "89x89"
},
{
"src": "windows11/SmallTile.scale-150.png",
"sizes": "107x107"
},
{
"src": "windows11/SmallTile.scale-200.png",
"sizes": "142x142"
},
{
"src": "windows11/SmallTile.scale-400.png",
"sizes": "284x284"
},
{
"src": "windows11/Square150x150Logo.scale-100.png",
"sizes": "150x150"
},
{
"src": "windows11/Square150x150Logo.scale-125.png",
"sizes": "188x188"
},
{
"src": "windows11/Square150x150Logo.scale-150.png",
"sizes": "225x225"
},
{
"src": "windows11/Square150x150Logo.scale-200.png",
"sizes": "300x300"
},
{
"src": "windows11/Square150x150Logo.scale-400.png",
"sizes": "600x600"
},
{
"src": "windows11/Wide310x150Logo.scale-100.png",
"sizes": "310x150"
},
{
"src": "windows11/Wide310x150Logo.scale-125.png",
"sizes": "388x188"
},
{
"src": "windows11/Wide310x150Logo.scale-150.png",
"sizes": "465x225"
},
{
"src": "windows11/Wide310x150Logo.scale-200.png",
"sizes": "620x300"
},
{
"src": "windows11/Wide310x150Logo.scale-400.png",
"sizes": "1240x600"
},
{
"src": "windows11/LargeTile.scale-100.png",
"sizes": "310x310"
},
{
"src": "windows11/LargeTile.scale-125.png",
"sizes": "388x388"
},
{
"src": "windows11/LargeTile.scale-150.png",
"sizes": "465x465"
},
{
"src": "windows11/LargeTile.scale-200.png",
"sizes": "620x620"
},
{
"src": "windows11/LargeTile.scale-400.png",
"sizes": "1240x1240"
},
{
"src": "windows11/Square44x44Logo.scale-100.png",
"sizes": "44x44"
},
{
"src": "windows11/Square44x44Logo.scale-125.png",
"sizes": "55x55"
},
{
"src": "windows11/Square44x44Logo.scale-150.png",
"sizes": "66x66"
},
{
"src": "windows11/Square44x44Logo.scale-200.png",
"sizes": "88x88"
},
{
"src": "windows11/Square44x44Logo.scale-400.png",
"sizes": "176x176"
},
{
"src": "windows11/StoreLogo.scale-100.png",
"sizes": "50x50"
},
{
"src": "windows11/StoreLogo.scale-125.png",
"sizes": "63x63"
},
{
"src": "windows11/StoreLogo.scale-150.png",
"sizes": "75x75"
},
{
"src": "windows11/StoreLogo.scale-200.png",
"sizes": "100x100"
},
{
"src": "windows11/StoreLogo.scale-400.png",
"sizes": "200x200"
},
{
"src": "windows11/SplashScreen.scale-100.png",
"sizes": "620x300"
},
{
"src": "windows11/SplashScreen.scale-125.png",
"sizes": "775x375"
},
{
"src": "windows11/SplashScreen.scale-150.png",
"sizes": "930x450"
},
{
"src": "windows11/SplashScreen.scale-200.png",
"sizes": "1240x600"
},
{
"src": "windows11/SplashScreen.scale-400.png",
"sizes": "2480x1200"
},
{
"src": "windows11/Square44x44Logo.targetsize-16.png",
"sizes": "16x16"
},
{
"src": "windows11/Square44x44Logo.targetsize-20.png",
"sizes": "20x20"
},
{
"src": "windows11/Square44x44Logo.targetsize-24.png",
"sizes": "24x24"
},
{
"src": "windows11/Square44x44Logo.targetsize-30.png",
"sizes": "30x30"
},
{
"src": "windows11/Square44x44Logo.targetsize-32.png",
"sizes": "32x32"
},
{
"src": "windows11/Square44x44Logo.targetsize-36.png",
"sizes": "36x36"
},
{
"src": "windows11/Square44x44Logo.targetsize-40.png",
"sizes": "40x40"
},
{
"src": "windows11/Square44x44Logo.targetsize-44.png",
"sizes": "44x44"
},
{
"src": "windows11/Square44x44Logo.targetsize-48.png",
"sizes": "48x48"
},
{
"src": "windows11/Square44x44Logo.targetsize-60.png",
"sizes": "60x60"
},
{
"src": "windows11/Square44x44Logo.targetsize-64.png",
"sizes": "64x64"
},
{
"src": "windows11/Square44x44Logo.targetsize-72.png",
"sizes": "72x72"
},
{
"src": "windows11/Square44x44Logo.targetsize-80.png",
"sizes": "80x80"
},
{
"src": "windows11/Square44x44Logo.targetsize-96.png",
"sizes": "96x96"
},
{
"src": "windows11/Square44x44Logo.targetsize-256.png",
"sizes": "256x256"
},
{
"src": "windows11/Square44x44Logo.altform-unplated_targetsize-16.png",
"sizes": "16x16"
},
{
"src": "windows11/Square44x44Logo.altform-unplated_targetsize-20.png",
"sizes": "20x20"
},
{
"src": "windows11/Square44x44Logo.altform-unplated_targetsize-24.png",
"sizes": "24x24"
},
{
"src": "windows11/Square44x44Logo.altform-unplated_targetsize-30.png",
"sizes": "30x30"
},
{
"src": "windows11/Square44x44Logo.altform-unplated_targetsize-32.png",
"sizes": "32x32"
},
{
"src": "windows11/Square44x44Logo.altform-unplated_targetsize-36.png",
"sizes": "36x36"
},
{
"src": "windows11/Square44x44Logo.altform-unplated_targetsize-40.png",
"sizes": "40x40"
},
{
"src": "windows11/Square44x44Logo.altform-unplated_targetsize-44.png",
"sizes": "44x44"
},
{
"src": "windows11/Square44x44Logo.altform-unplated_targetsize-48.png",
"sizes": "48x48"
},
{
"src": "windows11/Square44x44Logo.altform-unplated_targetsize-60.png",
"sizes": "60x60"
},
{
"src": "windows11/Square44x44Logo.altform-unplated_targetsize-64.png",
"sizes": "64x64"
},
{
"src": "windows11/Square44x44Logo.altform-unplated_targetsize-72.png",
"sizes": "72x72"
},
{
"src": "windows11/Square44x44Logo.altform-unplated_targetsize-80.png",
"sizes": "80x80"
},
{
"src": "windows11/Square44x44Logo.altform-unplated_targetsize-96.png",
"sizes": "96x96"
},
{
"src": "windows11/Square44x44Logo.altform-unplated_targetsize-256.png",
"sizes": "256x256"
},
{
"src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-16.png",
"sizes": "16x16"
},
{
"src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-20.png",
"sizes": "20x20"
},
{
"src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-24.png",
"sizes": "24x24"
},
{
"src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-30.png",
"sizes": "30x30"
},
{
"src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-32.png",
"sizes": "32x32"
},
{
"src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-36.png",
"sizes": "36x36"
},
{
"src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-40.png",
"sizes": "40x40"
},
{
"src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-44.png",
"sizes": "44x44"
},
{
"src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-48.png",
"sizes": "48x48"
},
{
"src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-60.png",
"sizes": "60x60"
},
{
"src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-64.png",
"sizes": "64x64"
},
{
"src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-72.png",
"sizes": "72x72"
},
{
"src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-80.png",
"sizes": "80x80"
},
{
"src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-96.png",
"sizes": "96x96"
},
{
"src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-256.png",
"sizes": "256x256"
},
{
"src": "android/android-launchericon-512-512.png",
"sizes": "512x512"
},
{
"src": "android/android-launchericon-192-192.png",
"sizes": "192x192"
},
{
"src": "android/android-launchericon-144-144.png",
"sizes": "144x144"
},
{
"src": "android/android-launchericon-96-96.png",
"sizes": "96x96"
},
{
"src": "android/android-launchericon-72-72.png",
"sizes": "72x72"
},
{
"src": "android/android-launchericon-48-48.png",
"sizes": "48x48"
},
{
"src": "ios/16.png",
"sizes": "16x16"
},
{
"src": "ios/20.png",
"sizes": "20x20"
},
{
"src": "ios/29.png",
"sizes": "29x29"
},
{
"src": "ios/32.png",
"sizes": "32x32"
},
{
"src": "ios/40.png",
"sizes": "40x40"
},
{
"src": "ios/50.png",
"sizes": "50x50"
},
{
"src": "ios/57.png",
"sizes": "57x57"
},
{
"src": "ios/58.png",
"sizes": "58x58"
},
{
"src": "ios/60.png",
"sizes": "60x60"
},
{
"src": "ios/64.png",
"sizes": "64x64"
},
{
"src": "ios/72.png",
"sizes": "72x72"
},
{
"src": "ios/76.png",
"sizes": "76x76"
},
{
"src": "ios/80.png",
"sizes": "80x80"
},
{
"src": "ios/87.png",
"sizes": "87x87"
},
{
"src": "ios/100.png",
"sizes": "100x100"
},
{
"src": "ios/114.png",
"sizes": "114x114"
},
{
"src": "ios/120.png",
"sizes": "120x120"
},
{
"src": "ios/128.png",
"sizes": "128x128"
},
{
"src": "ios/144.png",
"sizes": "144x144"
},
{
"src": "ios/152.png",
"sizes": "152x152"
},
{
"src": "ios/167.png",
"sizes": "167x167"
},
{
"src": "ios/180.png",
"sizes": "180x180"
},
{
"src": "ios/192.png",
"sizes": "192x192"
},
{
"src": "ios/256.png",
"sizes": "256x256"
},
{
"src": "ios/512.png",
"sizes": "512x512"
},
{
"src": "ios/1024.png",
"sizes": "1024x1024"
}
],
"screenshots": [
{
"src": "screenshot1.webp",
"sizes": "1280x720",
"type": "image/webp"
},
{
"src": "screenshot2.webp",
"sizes": "1280x720",
"type": "image/webp"
}
],
"theme_color": "#000000",
"background_color": "#000000",
"start_url": "sign-in",
"display": "standalone",
"orientation": "any",
"categories": [
"productivity"
],
"description": "Open Source kanban with MIT license",
"dir": "auto",
"prefer_related_applications": false,
"display_override": [
"standalone"
]
}
`;
export const DEFAULT_ASSETLINKS = `[ {
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "team.wekan.boards.twa",
"sha256_cert_fingerprints": [
"AA:AA:ED:7D:4C:9C:5A:A3:B5:DA:10:66:14:34:07:5D:EB:BE:96:CD:82:7B:09:46:47:13:65:29:5B:EA:96:30",
"61:41:86:5B:05:13:9B:64:5F:39:75:5A:16:C3:F2:22:25:6C:DA:74:B9:B0:8C:5F:93:B0:D2:26:65:16:1B:E6"
]
}
}
]
`;

View file

@ -118,6 +118,34 @@ Settings.attachSchema(
type: String,
optional: true,
},
customHeadEnabled: {
type: Boolean,
optional: true,
},
customHeadMetaTags: {
type: String,
optional: true,
},
customHeadLinkTags: {
type: String,
optional: true,
},
customManifestEnabled: {
type: Boolean,
optional: true,
},
customManifestContent: {
type: String,
optional: true,
},
customAssetLinksEnabled: {
type: Boolean,
optional: true,
},
customAssetLinksContent: {
type: String,
optional: true,
},
accessibilityPageEnabled: {
type: Boolean,
optional: true,

View file

@ -48,6 +48,10 @@ import './migrations/comprehensiveBoardMigration';
// Import file serving routes
import './routes/universalFileServer';
import './routes/customHeadAssets';
// Import server-side custom head rendering
import './lib/customHeadRender';
// Note: Automatic migrations are disabled - migrations only run when opening boards
// import './boardMigrationDetector';

View file

@ -0,0 +1,54 @@
import { WebApp } from 'meteor/webapp';
import { WebAppInternals } from 'meteor/webapp';
import Settings from '/models/settings';
Meteor.startup(() => {
// Use Meteor's official API to modify the HTML boilerplate
WebAppInternals.registerBoilerplateDataCallback('wekan-custom-head', (request, data) => {
try {
const setting = Settings.findOne();
// Initialize head array if it doesn't exist
if (!data.head) {
data.head = '';
}
// Always set title tag based on productName
const productName = (setting && setting.productName) ? setting.productName : 'Wekan';
data.head += `\n <title>${productName}</title>\n`;
// Only add custom head tags if enabled
if (!setting || !setting.customHeadEnabled) {
return data;
}
let injection = '';
// Add custom link tags (except manifest if custom manifest is enabled)
if (setting.customHeadLinkTags && setting.customHeadLinkTags.trim()) {
let linkTags = setting.customHeadLinkTags;
if (setting.customManifestEnabled) {
// Remove any manifest links from custom link tags to avoid duplicates
linkTags = linkTags.replace(/<link[^>]*rel=["\']?manifest["\']?[^>]*>/gi, '');
}
if (linkTags.trim()) {
injection += linkTags + '\n';
}
}
// Add manifest link if custom manifest is enabled
if (setting.customManifestEnabled) {
injection += ' <link rel="manifest" href="/site.webmanifest" crossorigin="use-credentials">\n';
}
if (injection.trim()) {
// Append custom head content to the existing head
data.head += injection;
}
return data;
} catch (e) {
console.error('[Custom Head] Error in boilerplate callback:', e.message, e.stack);
return data;
}
});
});

View file

@ -38,6 +38,13 @@ Meteor.publish('setting', () => {
oidcBtnText: 1,
mailDomainName: 1,
legalNotice: 1,
customHeadEnabled: 1,
customHeadMetaTags: 1,
customHeadLinkTags: 1,
customManifestEnabled: 1,
customManifestContent: 1,
customAssetLinksEnabled: 1,
customAssetLinksContent: 1,
accessibilityPageEnabled: 1,
accessibilityTitle: 1,
accessibilityContent: 1,

View file

@ -0,0 +1,81 @@
import { WebApp } from 'meteor/webapp';
import { Meteor } from 'meteor/meteor';
import fs from 'fs';
import path from 'path';
import Settings from '/models/settings';
const shouldServeContent = (value) =>
typeof value === 'string' && value.trim().length > 0;
const getDefaultFileContent = (filename) => {
try {
const filePath = path.join(Meteor.absolutePath, 'public', filename);
if (fs.existsSync(filePath)) {
return fs.readFileSync(filePath, 'utf-8');
}
} catch (e) {
console.error(`Error reading default file ${filename}:`, e);
}
return null;
};
const respondWithText = (res, contentType, body) => {
res.writeHead(200, {
'Content-Type': `${contentType}; charset=utf-8`,
'Access-Control-Allow-Origin': '*',
});
res.end(body);
};
WebApp.connectHandlers.use('/site.webmanifest', (req, res, next) => {
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
const setting = Settings.findOne(
{},
{
fields: {
customHeadEnabled: 1,
customManifestEnabled: 1,
customManifestContent: 1,
},
},
);
// Serve custom content if enabled
if (setting && setting.customHeadEnabled && setting.customManifestEnabled && shouldServeContent(setting.customManifestContent)) {
return respondWithText(res, 'application/manifest+json', setting.customManifestContent);
}
// Fallback to default manifest file
const defaultContent = getDefaultFileContent('site.webmanifest.default');
if (defaultContent) {
return respondWithText(res, 'application/manifest+json', defaultContent);
}
return next();
});
WebApp.connectHandlers.use('/.well-known/assetlinks.json', (req, res, next) => {
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
const setting = Settings.findOne(
{},
{
fields: {
customAssetLinksEnabled: 1,
customAssetLinksContent: 1,
},
},
);
// Serve custom content if enabled
if (setting && setting.customAssetLinksEnabled && shouldServeContent(setting.customAssetLinksContent)) {
return respondWithText(res, 'application/json', setting.customAssetLinksContent);
}
// Fallback to default assetlinks file
const defaultContent = getDefaultFileContent('.well-known/assetlinks.json.default');
if (defaultContent) {
return respondWithText(res, 'application/json', defaultContent);
}
return next();
});