From 55b6a7842ed8eda41637d3a26f0f7f23c0866478 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Wed, 25 Jan 2023 11:03:19 +0000
Subject: [PATCH 1/3] Added ability to control app icon (favicon) via settings

---
 app/Http/Controllers/SettingController.php    |  36 +------
 app/Settings/AppSettingsStore.php             |  90 ++++++++++++++++++
 app/Settings/SettingService.php               |  12 +--
 app/Uploads/ImageRepo.php                     |  10 +-
 public/icon-128.png                           | Bin 0 -> 3538 bytes
 public/icon-32.png                            | Bin 0 -> 1338 bytes
 public/icon-64.png                            | Bin 0 -> 1951 bytes
 public/icon.png                               | Bin 0 -> 6900 bytes
 resources/lang/en/settings.php                |   2 +-
 resources/views/layouts/base.blade.php        |   6 ++
 .../views/settings/customization.blade.php    |  19 ++++
 11 files changed, 132 insertions(+), 43 deletions(-)
 create mode 100644 app/Settings/AppSettingsStore.php
 create mode 100644 public/icon-128.png
 create mode 100644 public/icon-32.png
 create mode 100644 public/icon-64.png
 create mode 100644 public/icon.png

diff --git a/app/Http/Controllers/SettingController.php b/app/Http/Controllers/SettingController.php
index f5e48ca4c..1e13d7cb7 100644
--- a/app/Http/Controllers/SettingController.php
+++ b/app/Http/Controllers/SettingController.php
@@ -4,20 +4,14 @@ namespace BookStack\Http\Controllers;
 
 use BookStack\Actions\ActivityType;
 use BookStack\Auth\User;
+use BookStack\Settings\AppSettingsStore;
 use BookStack\Uploads\ImageRepo;
 use Illuminate\Http\Request;
 
 class SettingController extends Controller
 {
-    protected ImageRepo $imageRepo;
-
     protected array $settingCategories = ['features', 'customization', 'registration'];
 
-    public function __construct(ImageRepo $imageRepo)
-    {
-        $this->imageRepo = $imageRepo;
-    }
-
     /**
      * Handle requests to the settings index path.
      */
@@ -48,37 +42,17 @@ class SettingController extends Controller
     /**
      * Update the specified settings in storage.
      */
-    public function update(Request $request, string $category)
+    public function update(Request $request, AppSettingsStore $store, string $category)
     {
         $this->ensureCategoryExists($category);
         $this->preventAccessInDemoMode();
         $this->checkPermission('settings-manage');
         $this->validate($request, [
-            'app_logo' => array_merge(['nullable'], $this->getImageValidationRules()),
+            'app_logo' => ['nullable', ...$this->getImageValidationRules()],
+            'app_icon' => ['nullable', ...$this->getImageValidationRules()],
         ]);
 
-        // Cycles through posted settings and update them
-        foreach ($request->all() as $name => $value) {
-            $key = str_replace('setting-', '', trim($name));
-            if (strpos($name, 'setting-') !== 0) {
-                continue;
-            }
-            setting()->put($key, $value);
-        }
-
-        // Update logo image if set
-        if ($category === 'customization' && $request->hasFile('app_logo')) {
-            $logoFile = $request->file('app_logo');
-            $this->imageRepo->destroyByType('system');
-            $image = $this->imageRepo->saveNew($logoFile, 'system', 0, null, 86);
-            setting()->put('app-logo', $image->url);
-        }
-
-        // Clear logo image if requested
-        if ($category === 'customization' && $request->get('app_logo_reset', null)) {
-            $this->imageRepo->destroyByType('system');
-            setting()->remove('app-logo');
-        }
+        $store->storeFromUpdateRequest($request, $category);
 
         $this->logActivity(ActivityType::SETTINGS_UPDATE, $category);
         $this->showSuccessNotification(trans('settings.settings_save_success'));
diff --git a/app/Settings/AppSettingsStore.php b/app/Settings/AppSettingsStore.php
new file mode 100644
index 000000000..f2b6cdc52
--- /dev/null
+++ b/app/Settings/AppSettingsStore.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace BookStack\Settings;
+
+use BookStack\Uploads\ImageRepo;
+use Illuminate\Http\Request;
+
+class AppSettingsStore
+{
+    protected ImageRepo $imageRepo;
+
+    public function __construct(ImageRepo $imageRepo)
+    {
+        $this->imageRepo = $imageRepo;
+    }
+
+    public function storeFromUpdateRequest(Request $request, string $category)
+    {
+        $this->storeSimpleSettings($request);
+        if ($category === 'customization') {
+            $this->updateAppLogo($request);
+            $this->updateAppIcon($request);
+        }
+    }
+
+    protected function updateAppIcon(Request $request): void
+    {
+        $sizes = [128, 64, 32];
+
+        // Update icon image if set
+        if ($request->hasFile('app_icon')) {
+            $iconFile = $request->file('app_icon');
+            $this->destroyExistingSettingImage('app-icon');
+            $image = $this->imageRepo->saveNew($iconFile, 'system', 0, 256, 256);
+            setting()->put('app-icon', $image->url);
+
+            foreach ($sizes as $size) {
+                $icon = $this->imageRepo->saveNew($iconFile, 'system', 0, $size, $size);
+                setting()->put('app-icon-' . $size, $icon->url);
+            }
+        }
+
+        // Clear icon image if requested
+        if ($request->get('app_icon_reset')) {
+            $this->destroyExistingSettingImage('app-icon');
+            setting()->remove('app-icon');
+            foreach ($sizes as $size) {
+                $this->destroyExistingSettingImage('app-icon-' . $size);
+                setting()->remove('app-icon-' . $size);
+            }
+        }
+    }
+
+    protected function updateAppLogo(Request $request): void
+    {
+        // Update logo image if set
+        if ($request->hasFile('app_logo')) {
+            $logoFile = $request->file('app_logo');
+            $this->destroyExistingSettingImage('app-logo');
+            $image = $this->imageRepo->saveNew($logoFile, 'system', 0, null, 86);
+            setting()->put('app-logo', $image->url);
+        }
+
+        // Clear logo image if requested
+        if ($request->get('app_logo_reset')) {
+            $this->destroyExistingSettingImage('app-logo');
+            setting()->remove('app-logo');
+        }
+    }
+
+    protected function storeSimpleSettings(Request $request): void
+    {
+        foreach ($request->all() as $name => $value) {
+            if (strpos($name, 'setting-') !== 0) {
+                continue;
+            }
+
+            $key = str_replace('setting-', '', trim($name));
+            setting()->put($key, $value);
+        }
+    }
+
+    protected function destroyExistingSettingImage(string $settingKey)
+    {
+        $existingVal = setting()->get($settingKey);
+        if ($existingVal) {
+            $this->imageRepo->destroyByUrlAndType($existingVal, 'system');
+        }
+    }
+}
diff --git a/app/Settings/SettingService.php b/app/Settings/SettingService.php
index 9f0a41ea2..d1bac164d 100644
--- a/app/Settings/SettingService.php
+++ b/app/Settings/SettingService.php
@@ -12,15 +12,11 @@ use Illuminate\Contracts\Cache\Repository as Cache;
  */
 class SettingService
 {
-    protected $setting;
-    protected $cache;
-    protected $localCache = [];
+    protected Setting $setting;
+    protected Cache $cache;
+    protected array $localCache = [];
+    protected string $cachePrefix = 'setting-';
 
-    protected $cachePrefix = 'setting-';
-
-    /**
-     * SettingService constructor.
-     */
     public function __construct(Setting $setting, Cache $cache)
     {
         $this->setting = $setting;
diff --git a/app/Uploads/ImageRepo.php b/app/Uploads/ImageRepo.php
index 8770402ad..910248203 100644
--- a/app/Uploads/ImageRepo.php
+++ b/app/Uploads/ImageRepo.php
@@ -180,13 +180,17 @@ class ImageRepo
     }
 
     /**
-     * Destroy all images of a certain type.
+     * Destroy images that have a specific URL and type combination.
      *
      * @throws Exception
      */
-    public function destroyByType(string $imageType): void
+    public function destroyByUrlAndType(string $url, string $imageType): void
     {
-        $images = Image::query()->where('type', '=', $imageType)->get();
+        $images = Image::query()
+            ->where('url', '=', $url)
+            ->where('type', '=', $imageType)
+            ->get();
+
         foreach ($images as $image) {
             $this->destroyImage($image);
         }
diff --git a/public/icon-128.png b/public/icon-128.png
new file mode 100644
index 0000000000000000000000000000000000000000..46cc2811b5e7f6162e87b8e4e0cf3785f57a922c
GIT binary patch
literal 3538
zcmV;@4K4DCP)<h;3K|Lk000e1NJLTq004jh004jp0{{R3^x%>C0008|P)t-s03iSk
zhtL3v(h-Q!0FBiYiP9H})D(@-8jIB&jMf*9(I1W18j#W~jL;mB(=LtCAClBCj?y8M
z)-jLMBb3)OkkloW*fo*XCzjbZlGiws*ff>XIh5Hpmef0y+Cr4mH<;B#mDD+z)<u@p
zI-1x<m)1O+*+`h!J)GJ}nb|*`+e(_+N1WA5o7+vC+)19+Q=8RFpVw5J)=Qw+Rh`#N
zq1slS*;k+1PNLgbpxaNQ+)$+5TA|!grQTbj-C?5FT%+DtrPyMl*jc98W2D(yr`lwt
z+FYpIWv1L+soiF$-CnBRXQ<v^tKVs<-(alZZmHR7s^D*`+GnlXYpmgLtJ`R<+;Obj
za;@HZtKDp|;B&9vbg<xjuHbdC;dZj(ceCSsv)zQT-F>v)g|gm$wcm!b;D5H^hqU2<
zx8jJk;(@s1iMHc{x#Wts<b=BAi@4>5yXJ|z;E=iCjJoEAyyuF%;gP!Gi@oBKyyJ|%
z<dePRjlboTzU7X<=9RzZkHP1bz~_*{=$OIio5A9h!{d>}>6yamoWkRl#N?92>Yc;n
zm&WCu#O9dC=by#rnaJp%#^{>K>7mEzo672=$m*oY>!-=(rONE6%I2xd=cdf<s?6!D
z&g!zv>a5S}v(4+R(CoC%?5@%6wa@LZ((b9$?zYhGxzOmb)9<>`>9o`7vefXq((1L;
z>a*4IywmKq)$6^~?YGwLxYzE!*6z93@4wgYy4mo-*zmjC@xs~hyxj7`+VaNQ?7iLd
z#M|@0-R;NQ?Y`dh!QSr3-0#BQ@X6iq!{G7C-to)d^334##^UqG<Mhqq^vLA(&g1pb
z<L}Sp_R{3=(B=5k<?+<!^VR3{*68)u>Gs*`_uA|D;Oh9@?DOI4`P=RJ;_Um}?)u*D
z^ycmM;P3V4?)TyF_vr8V;_>+D@cHBN`Reid<n#LL^84lV`{woh?DYKY_5JVm|MB+t
z^7s1l`1|zv{Pp_%_xt|%{Qvp>|N8&`{Qvv^|No39j~4&{00DGTPE!Ct=GbNc0004E
zOGiWihy@);00009a7bBm001Cy001Cy0qf8I3jhELWJyFpRCwC$TTMtK*%t1isE`l}
z2@=9{6GR$mW+M`mhJk=IO*1QL5XXfZM?^$qAPbR3X5*qkL}u}jmTqaJVWgV|@exFt
znS~If9T*U?HNK=5CS#sRz(>pvA&aTH=iW+H-E(6lCc5dnnz6Ocse8`(zVlZP9(+6B
zPLocBocor4Zz>ScrQCgK0I!}L@7pTehYBG6Vksa=o{^){ovT0u=HB%vl74)%U+>lx
z!1#NEZiyZ_xSG8?6^Nj6azd3%M+qO?l?Fsib|olTPVjZ^ZbFiX$Zv)ei+FV=L7XDf
zY8Alv2SXl<c+b#*9tMR^1Kn#aMFn`hM5N6kUI}gH^`D=NdL_v<@Tu4`6(IChgR<55
zE5!dV7X9%5-m!RV>_8q`*nVs?{zS42h)`84uz1mY6~OpMBVLPm_u%J}4G{<`^t@}b
zE5Hk-X4|dC@7W-7POXSQG#hT}C4lkQI<3YJEM4k9ztri#cGPiVm?o(}9#h<X;<C#i
zh_KbG*u}e^#E4-|vdz!{UgKm;waM5mVu+wSK-U}HFy%zV7uvkOtqLGMvtTv840uyM
zo92u!wa=XssD7nKtNZ!>?T8wT|F+v|eE&iU=%9lW?;ZU@FfrOk4Wmw<HSP)!dY_-U
zY%-1k5&C)KhyNhS(U%dEVLDzl7?L#OnzR}J@lFv#1cHgHL{#b*TeOC^^YyF1GOi98
zlW{)hm+j(xKc|Vo-aO>~$y<NUMt=SJw;tOq2Twq;FWYZd<|m%Fn`HnY?tJf(kmH+8
z)#D0a{6DSus-KZkrFu%m7&DL3w^b!X^vFTyL=72NBEpB5)Nx!itB`+8e+-0}dFhwL
z$2AahmcCc2Va%Vv;ONZa>yNR&;{UtSer=9K+EfAv@sTXmJT!i4s{n7d)u+^YlH&HN
z?cG`uw&h}kSd2Y&OE(_>FE_XXCEa1q&CiltQ7uUQln~?Ns^sLz58u{s1xC6)N6(Vx
zHK$*rgxDcV4o~2NPytH1T~41BhUBkuz1gcc6<@;*XvP(GIenJYo`w|pxyR{WxWd~7
zL6$)AWyC8vKkgm9KAKjY{=VD=wM%A?QvfI38_!@Ov*O(Nz88e2bL-u+b_Hmq+b!28
zUy}U+$Hv!6c;HqoT5SqI#^orC8$L=1ni!D2S`+}>jgP4<6fXx?F3sF(Qh<`~qQ8aW
zB{f2lqU-mpcBv`=VZ6~Jw@|$MIpOKjL6uvX0<_W{Y^Cw#-j9T*lQSk8Qw7kBtI6U6
zivZOt7H%~wu-VG%S0_$^TW#G8P@uvbFwiRF50aVy;Z{R|1c}Y~n70+T-@QSqqSPGE
zt#ed?;uEMMik}8t&6e+9$cVVrJE|K-zkp0Y^3R`(Qt8_qqjurB)itm~?p;?P*`{YG
zkgTQ_nukShntdMg+{(m(0w*fexqBqBI38_|E3l*s<5nLSro?VQWtBeZDiwG7mD|g2
zpZgmMT0TYg{0M}&w`MW{>nah=t^~Pw9ma<mtPp$38DHs(5==ZDS7)nESnVpULsMb4
z?H1x)-$NA||4HQuaW!ke4#R=`L!{qzG~#L0_??h(T_W7t7Wrx-M5aJ##0s2Kr0G+z
z1E<5&7D-!4S4@Zo5-!`X=~vQQyIlTP-LAl7eCD}T?%A{vqM^X9{g;*A{}B94PSwYe
z3hhRnP9nx%215L`>fnX~7ku0y>4W2p>vB23-JCiN%{)u(Qf0$SgbJxG^B<iOwK{c1
zeMc4&qSdp_bR6gLPxIz0=b57W2Z)|SK6>OwK!{)M#ik`6D1LgvV@5biiTWm2kiUDU
z%P6NKJbw6J0=m8Klm+=fVHK!wjju?&!-r0a{=gEDE`p%y7-=UJqP(9e3ls_CzE0q#
zzY!{=U8UT**cE8E7?a_C8Y_wjv2*o`DiBp_Q6Xc_jDV=MKPAS;{<Q0@@K06naiYL0
z^Hpt!k*4<_B3{>IfO_Zi;>$x!>mEEPRr7(D%vpdyd1n!4HZ+s^|C=sRQ?h{Mc@*c$
z*HnQ;BV5)=xTKNZ44T_%dJz}hMP!5_<3bvS8qyQI%Kn=<;cMfW?q@<Ol)GX4bN<3-
z$RMMF%E=hwU|<*wLYT{L^(ibL*fv>RofJ|bgBs9|k#0r|qB*44bQ!Pcr8r>ZgiQGk
zMjCw2gXV1cXh@;Njzy;jumdH;M<n0TFtN=HM7b{84D9_iS4SE(9wF-N8gZ}^f=@~a
zy2Z!@jb(hs$kTx0NiIm>9S%kiQim!RmNpR5eU+tz$mfY;H0Lz}-B2Lv;$ec^a9*6&
zXfhBRO(oHpIw?e`%bLQ%P#}u{C3_Xb$JCi*=^By2(n)lJhSfb)HpK9crO$EfqJJ9T
zjSt;o!2}H*qL}Fz!#~e3xY}nYjk5p*54TWJouKixuc1H*dec0Wx6z*B#!V9dhAumg
z6%i&TdAu^ha2yh*!y$LQRA?R19wB9A5QcRZ*kE~;0@0z$dMKFp(ib=uwvU;T=~=!C
zZJRkOoA-?PXwnsUFKY<1gmsZ9tpODZ+81<uijwH?0k%~@6ZpJA$1A2jS~-c#s0=E6
zD9X5szlV`36fiM4p^6fC+9P|(+Q@)^e_|L8RG4|;vpE<eL|$t;LBqm=u6r0=%4K^m
z^#g)`kldAyhP~!W7tjTn(rI2*7I>BWhduN7RQr#0*>p9uc;@HfDnPlGdeX~opG}K1
zE-@zB*Gg=qq0$ef!09+fX2ft;tGc_<jH|SVv>@mNEgcaOqP0zb=CeBemx|!Y8Wlp$
z7V-7N&?kxNc>!U86$!{{k!Gln4Y?`)Ax=-T+0yY95F#ccDbB8A*m9Njg9X7<DTOq+
z)T#f{bRhQ|f-XS1*j51}L~KsBuku|UihoE_+d-1r(mocgP>5h7Pyc*MJSZ%zVWj{R
zD!(HscWCPxo6`9gps-jAVueMi=AabQnK={E=5+`CqT;0(<27dYS*bOsJ{<Ola@drE
zo}Iy2qgZF|rj-TZsNVf7CfroPI&+zY08$}3S+o@{E6}LQN5eIGc2q`gy}R+X6;Sdk
zhFw6^8m@?Ep#&TDks_g<WozNWiV~vuhmNP&EEMbLLBKj++5vd^&?C&Z`Os&T?!}U@
zNT_>h2wj6vrxUQEU}aaWLJ+oP2OC;n6W3fczc4pCJ>q7jLTRhg=;9hivi(q~FzslS
zSVkQeh`Yh<J$!XfGN%A;V-}iSaY(xL+X3s@8C`*1+9|-m>uK!v8_oFoCh>ZJyd0f{
z3R@7jUSXvRtXTJHmu~H(UI=?xP1)kD2=gjJt@teTxamv8+!8Y+7e|}OEHu2+?2laV
zHE!L~s<4c0@+v#5Rqcdt9AD8ywgub#721cdDc<vA3%~Dy7faXJ)MA9#vJX1I750Zb
zqES`0<)5@vMC}U=9Dh#S$yYu2Cq0sT=&*E`9~Id?YBJiU_w!ko99X)z<4@Zv<|e#4
zd0M%jPwU9+zrl>FSt`V*H}388$_J*6?)&=2^`6Bu4IX?u-%fMRKkhgw!oC+g5dZ)H
M07*qoM6N<$f;;1MtpET3

literal 0
HcmV?d00001

diff --git a/public/icon-32.png b/public/icon-32.png
new file mode 100644
index 0000000000000000000000000000000000000000..7307ed8f1e88f1e5ade85925d8aedee40d771f64
GIT binary patch
literal 1338
zcmV-A1;zS_P)<h;3K|Lk000e1NJLTq001BW001Be0{{R3M5Kzw0008|P)t-s0C4~f
zhtL3v(h-Q!0FBiYiP9H})D(@-8jIB&jMf*9(I1W18j#W~jL;mB(=LtCAClBCj?y8M
z)-jLMBb3)OkkloW*fo*XCzjbZlGiws*ff>XIh5Hpmef0y+Cr4mH<;B#mDD+z)<u@p
zI-1x<m)1O+*+`h!J)GJ}nb|*`+e(_+N1WA5o7+vC+)19+Q=8RFpVw5J)=Qw+Rh`#N
zq1slS*;k+1PNLgbpxaNQ+)$+5TA|!grQTbj-C?5FT%+DtrPyMl*jc98W2D(yr`lwt
z+FYpIWv1L+soiF$-CnBRXQ<v^tKVs<-(alZZmHR7s^D*`+GnlXYpmgLtJ`R<+;Obj
za;@HZtKDp|;B&9vbg<xjuHbdC;dZj(ceCSsv)zQT-F>v)g|gm$wcm!b;D5H^hqU2<
zx8jJk;(@s1iMHc{x#Wts<b=BAi@4>5yXJ|z;E=iCjJoEAyyuF%;gP!Gi@oBKyyJ|%
z<dePRjlboTzU7X<=9RzZkHP1bz~_*{=$OIio5A9h!{d>}>6yamoWkRl#N?92>Yc;n
zm&WCu#O9dC=by#rnaJp%#^{>K>7mEzo672=$m*oY>!-=(rONE6%I2xd=cdf<s?6!D
z&g!zv>a5S}v(4+R(CoC%?5@%6wa@LZ((b9$?zYhGxzOmb)9<>`>9o`7vefXq((1L;
z>a*4IywmKq)$6^~?YGwLxYzE!*6z93@4wgYy4mo-*zmjC@xs~hyxj7`+VaNQ?7iLd
z#M|@0-R;NQ?Y`dh!QSr3-0#BQ@X6iq!{G7C-to)d^334##^UqG<Mhqq^vLA(&g1pb
z<L}Sp_R{3=(B=5k<?+<!^VR3{*68)u>Gs*`_uA|D;Oh9@?DOI4`P=RJ;_Um}?)u*D
z^ycmM;P3V4?)TyF_vr8V;_>+D@cHBN`Reid<n#LL^84lV`{woh?DYKY_5JVm|MB+t
z^7s1l`1|zv{Pp_%_xt|%{Qvp>|N8&`{Qvv^|Nn}pJ+%M;00DGTPE!Ct=GbNc0004E
zOGiWihy@);00009a7bBm001Cy001Cy0qf8I3jhEC%t=H+R5;6(Q$1+HKorhLuqYBr
z5$PC;l&(_Tg;F|)Zmz+>-9buMhk`f<m&_s*q|h~kP^6<sv`dy=f{1iV{TXiPyE|>g
zTA^=AF5j1T-}~Nsn)d&QR|ny_dwezIf5N<%2TDPY?Wgdd1Qhz?Tcn-&9{i)OBjd6f
zfD141c9SgHS?G7nJpqgst0!QMwkiCzKtEb+5;jp~ol}UYPWblaDvHb>6gLNBpcwJP
z5x%2mf4^Q0{g~io$HcxcA6W&F#+RkNaIz6O28gg~L@nbtZU^24px-aFIm`~L?!bk?
z*n+UMA8VYvEe?EZ3(mHsS@|B-MjD2qsB-vxwube3<Q`LnZbW7gAy&4eoQac$Oe^|D
zX0_>S(phTL4cB}M%gDb-%dC+!%MQJn#?AF@z>ObBYR(IVSw*3gG;H)<iU*}NoT!-s
zPTR6J(qO-P=a7BO2n}V{rJM<+^u!e!QjYX2OOv3J``24ar=pQLp^ue?J$***1DAcJ
z^i8!=9<F_9$BiM<vO2w<|4fvcl?r<lP1S3RCX1AQ?oOLf-$>pDiXH6^<V<CqtncH0
wOgsuSS!Q>2U}unO#s{~~X8iEp^8fGp7Zf4e`k7Q;P5=M^07*qoM6N<$f&%O@y#N3J

literal 0
HcmV?d00001

diff --git a/public/icon-64.png b/public/icon-64.png
new file mode 100644
index 0000000000000000000000000000000000000000..854d80faacb4ddcbbef17a357a5e7c3ca6ed69b9
GIT binary patch
literal 1951
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

literal 0
HcmV?d00001

diff --git a/public/icon.png b/public/icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..b9f0125a856efffe6b8c8c0660810cff9b6636a6
GIT binary patch
literal 6900
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

literal 0
HcmV?d00001

diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php
index f4204dd68..318dc0a52 100755
--- a/resources/lang/en/settings.php
+++ b/resources/lang/en/settings.php
@@ -33,7 +33,7 @@ return [
     'app_custom_html_desc' => 'Any content added here will be inserted into the bottom of the <head> section of every page. This is handy for overriding styles or adding analytics code.',
     'app_custom_html_disabled_notice' => 'Custom HTML head content is disabled on this settings page to ensure any breaking changes can be reverted.',
     'app_logo' => 'Application Logo',
-    'app_logo_desc' => 'This image should be 43px in height. <br>Large images will be scaled down.',
+    'app_logo_desc' => 'This is used in the application header bar, among other areas. This image should be 86px in height. Large images will be scaled down.',
     'app_primary_color' => 'Application Primary Color',
     'app_primary_color_desc' => 'Sets the primary color for the application including the banner, buttons, and links.',
     'app_homepage' => 'Application Homepage',
diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php
index 76d220952..b09a8dfe9 100644
--- a/resources/views/layouts/base.blade.php
+++ b/resources/views/layouts/base.blade.php
@@ -20,6 +20,12 @@
     <link rel="stylesheet" href="{{ versioned_asset('dist/styles.css') }}">
     <link rel="stylesheet" media="print" href="{{ versioned_asset('dist/print-styles.css') }}">
 
+    <!-- Icons -->
+    <link rel="icon" type="image/png" sizes="256x256" href="{{ setting('app-icon') ?? url('/icon.png') }}">
+    <link rel="icon" type="image/png" sizes="128x128" href="{{ setting('app-icon-128') ?? url('/icon-128.png') }}">
+    <link rel="icon" type="image/png" sizes="64x64" href="{{ setting('app-icon-64') ?? url('/icon-64.png') }}">
+    <link rel="icon" type="image/png" sizes="32x32" href="{{ setting('app-icon-32') ?? url('/icon-32.png') }}">
+
     @yield('head')
 
     <!-- Custom Styles & Head Content -->
diff --git a/resources/views/settings/customization.blade.php b/resources/views/settings/customization.blade.php
index 3748267df..847704007 100644
--- a/resources/views/settings/customization.blade.php
+++ b/resources/views/settings/customization.blade.php
@@ -53,6 +53,25 @@
                 </div>
             </div>
 
+            <div class="grid half gap-xl">
+                <div>
+                    <label class="setting-list-label">{{ 'Application Icon' }}</label>
+                    <p class="small">
+                        This icon is used for browser tabs and shortcut icons.
+                        This should be a 256px square PNG image.
+                    </p>
+                </div>
+                <div class="pt-xs">
+                    @include('form.image-picker', [
+                             'removeValue' => 'none',
+                             'defaultImage' => url('/icon.png'),
+                             'currentImage' => setting('app-icon'),
+                             'name' => 'app_icon',
+                             'imageClass' => 'logo-image',
+                         ])
+                </div>
+            </div>
+
             <!-- Primary Color -->
             <div class="grid half gap-xl">
                 <div>

From 3c658e39abe32a7dc345f3c49ea012340d4427e2 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Wed, 25 Jan 2023 16:11:34 +0000
Subject: [PATCH 2/3] Extracted app icon text, fixed issues

Tweaked sizes and meta tags based unpon ipad testing.
Fixed reduced sizes not being cleaned up.
---
 app/Settings/AppSettingsStore.php                |  3 ++-
 app/Uploads/ImageRepo.php                        |  5 ++++-
 resources/lang/en/settings.php                   |  2 ++
 resources/views/layouts/base.blade.php           | 13 ++++++++-----
 resources/views/settings/customization.blade.php |  7 ++-----
 5 files changed, 18 insertions(+), 12 deletions(-)

diff --git a/app/Settings/AppSettingsStore.php b/app/Settings/AppSettingsStore.php
index f2b6cdc52..8d7b73c1c 100644
--- a/app/Settings/AppSettingsStore.php
+++ b/app/Settings/AppSettingsStore.php
@@ -25,7 +25,7 @@ class AppSettingsStore
 
     protected function updateAppIcon(Request $request): void
     {
-        $sizes = [128, 64, 32];
+        $sizes = [180, 128, 64, 32];
 
         // Update icon image if set
         if ($request->hasFile('app_icon')) {
@@ -35,6 +35,7 @@ class AppSettingsStore
             setting()->put('app-icon', $image->url);
 
             foreach ($sizes as $size) {
+                $this->destroyExistingSettingImage('app-icon-' . $size);
                 $icon = $this->imageRepo->saveNew($iconFile, 'system', 0, $size, $size);
                 setting()->put('app-icon-' . $size, $icon->url);
             }
diff --git a/app/Uploads/ImageRepo.php b/app/Uploads/ImageRepo.php
index 910248203..2c643a58b 100644
--- a/app/Uploads/ImageRepo.php
+++ b/app/Uploads/ImageRepo.php
@@ -123,7 +123,10 @@ class ImageRepo
     public function saveNew(UploadedFile $uploadFile, string $type, int $uploadedTo = 0, int $resizeWidth = null, int $resizeHeight = null, bool $keepRatio = true): Image
     {
         $image = $this->imageService->saveNewFromUpload($uploadFile, $type, $uploadedTo, $resizeWidth, $resizeHeight, $keepRatio);
-        $this->loadThumbs($image);
+
+        if ($type !== 'system') {
+            $this->loadThumbs($image);
+        }
 
         return $image;
     }
diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php
index 318dc0a52..023cf1beb 100755
--- a/resources/lang/en/settings.php
+++ b/resources/lang/en/settings.php
@@ -34,6 +34,8 @@ return [
     'app_custom_html_disabled_notice' => 'Custom HTML head content is disabled on this settings page to ensure any breaking changes can be reverted.',
     'app_logo' => 'Application Logo',
     'app_logo_desc' => 'This is used in the application header bar, among other areas. This image should be 86px in height. Large images will be scaled down.',
+    'app_icon' => 'Application Icon',
+    'app_icon_desc' => 'This icon is used for browser tabs and shortcut icons. This should be a 256px square PNG image.',
     'app_primary_color' => 'Application Primary Color',
     'app_primary_color_desc' => 'Sets the primary color for the application including the banner, buttons, and links.',
     'app_homepage' => 'Application Homepage',
diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php
index b09a8dfe9..e0a6f46d0 100644
--- a/resources/views/layouts/base.blade.php
+++ b/resources/views/layouts/base.blade.php
@@ -6,10 +6,11 @@
     <title>{{ isset($pageTitle) ? $pageTitle . ' | ' : '' }}{{ setting('app-name') }}</title>
 
     <!-- Meta -->
+    <meta charset="utf-8">
     <meta name="viewport" content="width=device-width">
     <meta name="token" content="{{ csrf_token() }}">
     <meta name="base-url" content="{{ url('/') }}">
-    <meta charset="utf-8">
+    <meta name="theme-color" content="{{ setting('app-color') }}"/>
 
     <!-- Social Cards Meta -->
     <meta property="og:title" content="{{ isset($pageTitle) ? $pageTitle . ' | ' : '' }}{{ setting('app-name') }}">
@@ -21,10 +22,12 @@
     <link rel="stylesheet" media="print" href="{{ versioned_asset('dist/print-styles.css') }}">
 
     <!-- Icons -->
-    <link rel="icon" type="image/png" sizes="256x256" href="{{ setting('app-icon') ?? url('/icon.png') }}">
-    <link rel="icon" type="image/png" sizes="128x128" href="{{ setting('app-icon-128') ?? url('/icon-128.png') }}">
-    <link rel="icon" type="image/png" sizes="64x64" href="{{ setting('app-icon-64') ?? url('/icon-64.png') }}">
-    <link rel="icon" type="image/png" sizes="32x32" href="{{ setting('app-icon-32') ?? url('/icon-32.png') }}">
+    <link rel="icon" type="image/png" sizes="256x256" href="{{ setting('app-icon') ?: url('/icon.png') }}">
+    <link rel="icon" type="image/png" sizes="180x180" href="{{ setting('app-icon-180') ?: url('/icon-180.png') }}">
+    <link rel="apple-touch-icon" sizes="180x180" href="{{ setting('app-icon-180') ?: url('/icon-180.png') }}">
+    <link rel="icon" type="image/png" sizes="128x128" href="{{ setting('app-icon-128') ?: url('/icon-128.png') }}">
+    <link rel="icon" type="image/png" sizes="64x64" href="{{ setting('app-icon-64') ?: url('/icon-64.png') }}">
+    <link rel="icon" type="image/png" sizes="32x32" href="{{ setting('app-icon-32') ?: url('/icon-32.png') }}">
 
     @yield('head')
 
diff --git a/resources/views/settings/customization.blade.php b/resources/views/settings/customization.blade.php
index 847704007..aa37c30c9 100644
--- a/resources/views/settings/customization.blade.php
+++ b/resources/views/settings/customization.blade.php
@@ -55,11 +55,8 @@
 
             <div class="grid half gap-xl">
                 <div>
-                    <label class="setting-list-label">{{ 'Application Icon' }}</label>
-                    <p class="small">
-                        This icon is used for browser tabs and shortcut icons.
-                        This should be a 256px square PNG image.
-                    </p>
+                    <label class="setting-list-label">{{ trans('settings.app_icon') }}</label>
+                    <p class="small">{{ trans('settings.app_icon_desc') }}</p>
                 </div>
                 <div class="pt-xs">
                     @include('form.image-picker', [

From a50b0ea1e5b3e3c02aacef653a8e3dd5d09eabd8 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Wed, 25 Jan 2023 16:41:41 +0000
Subject: [PATCH 3/3] Covered app icon setting with testing

---
 tests/Settings/SettingsTest.php | 46 +++++++++++++++++++++++++++++++++
 1 file changed, 46 insertions(+)

diff --git a/tests/Settings/SettingsTest.php b/tests/Settings/SettingsTest.php
index e2ac6f27c..1161a466e 100644
--- a/tests/Settings/SettingsTest.php
+++ b/tests/Settings/SettingsTest.php
@@ -2,10 +2,14 @@
 
 namespace Tests\Settings;
 
+use Illuminate\Support\Facades\Storage;
 use Tests\TestCase;
+use Tests\Uploads\UsesImages;
 
 class SettingsTest extends TestCase
 {
+    use UsesImages;
+
     public function test_settings_endpoint_redirects_to_settings_view()
     {
         $resp = $this->asAdmin()->get('/settings');
@@ -40,4 +44,46 @@ class SettingsTest extends TestCase
         $resp->assertStatus(404);
         $resp->assertSee('Page Not Found');
     }
+
+    public function test_updating_and_removing_app_icon()
+    {
+        $this->asAdmin();
+        $galleryFile = $this->getTestImage('my-app-icon.png');
+        $expectedPath = public_path('uploads/images/system/' . date('Y-m') . '/my-app-icon.png');
+
+        $this->assertFalse(setting()->get('app-icon'));
+        $this->assertFalse(setting()->get('app-icon-180'));
+        $this->assertFalse(setting()->get('app-icon-128'));
+        $this->assertFalse(setting()->get('app-icon-64'));
+        $this->assertFalse(setting()->get('app-icon-32'));
+
+        $prevFileCount = count(glob(dirname($expectedPath) . DIRECTORY_SEPARATOR . '*.png'));
+
+        $upload = $this->call('POST', '/settings/customization', [], [], ['app_icon' => $galleryFile], []);
+        $upload->assertRedirect('/settings/customization');
+
+        $this->assertTrue(file_exists($expectedPath), 'Uploaded image not found at path: ' . $expectedPath);
+        $this->assertStringContainsString('my-app-icon', setting()->get('app-icon'));
+        $this->assertStringContainsString('my-app-icon', setting()->get('app-icon-180'));
+        $this->assertStringContainsString('my-app-icon', setting()->get('app-icon-128'));
+        $this->assertStringContainsString('my-app-icon', setting()->get('app-icon-64'));
+        $this->assertStringContainsString('my-app-icon', setting()->get('app-icon-32'));
+
+        $newFileCount = count(glob(dirname($expectedPath) . DIRECTORY_SEPARATOR . '*.png'));
+        $this->assertEquals(5, $newFileCount - $prevFileCount);
+
+        $resp = $this->get('/');
+        $this->withHtml($resp)->assertElementCount('link[sizes][href*="my-app-icon"]', 6);
+
+        $reset = $this->post('/settings/customization', ['app_icon_reset' => 'true']);
+        $reset->assertRedirect('/settings/customization');
+
+        $resetFileCount = count(glob(dirname($expectedPath) . DIRECTORY_SEPARATOR . '*.png'));
+        $this->assertEquals($prevFileCount, $resetFileCount);
+        $this->assertFalse(setting()->get('app-icon'));
+        $this->assertFalse(setting()->get('app-icon-180'));
+        $this->assertFalse(setting()->get('app-icon-128'));
+        $this->assertFalse(setting()->get('app-icon-64'));
+        $this->assertFalse(setting()->get('app-icon-32'));
+    }
 }