macOS Tahoe: Have the calibre app icon automatically use a dark look when the system is in dark mode

Uses the new Liquid Glass infrastructure to generate the application
icons using actool which means icons have to be generated on a mac, but
that is done via a one-time script not per build. I could change it to
be done per build, but not needed and doing it that way makes iterating
on icon design too slow.
This commit is contained in:
Kovid Goyal
2026-04-18 12:17:31 +05:30
parent d889ac1b89
commit 49d1847d92
7 changed files with 140 additions and 223 deletions
+2 -1
View File
@@ -35,7 +35,8 @@
/resources/mozilla-ca-certs.pem
/resources/user-agent-data.json
/resources/piper-voices.json
/icons/icns/*/*.iconset
/icons/icns/*.icns
/icons/icns/*.car
/setup/installer/windows/calibre/build.log
/setup/pyqt_enums
/tags
+1 -1
View File
@@ -1,5 +1,5 @@
# Requires installation of XCode 16.2 and
# python3 -m pip install certifi html5lib icnsutil
# python3 -m pip install certifi html5lib
vm_name 'macos-calibre'
root '/Users/Shared/calibre-build'
+9 -9
View File
@@ -387,17 +387,14 @@ class Freeze:
@flush
def create_skeleton(self):
print('Creating skeleton')
c = join(self.build_dir, 'Contents')
for x in ('Frameworks', 'MacOS', 'Resources'):
os.makedirs(join(c, x))
icons = glob.glob(join(CALIBRE_DIR, 'icons', 'icns', 'light', '*.iconset'))
if not icons:
raise SystemExit('Failed to find icns format icons')
for x in icons:
output_icns = join(self.resources_dir, basename(x).partition('.')[0] + '.icns')
xd = x.replace('/light/', '/dark/')
assert x != xd
generate_icns(x, xd, output_icns)
icns_dir = join(CALIBRE_DIR, 'icons', 'icns')
shutil.copy(join(icns_dir, 'Assets.car'), self.resources_dir)
for x in glob.glob(join(icns_dir, '*.icns')):
shutil.copy(x, self.resources_dir)
for helpers in (self.helpers_dir,):
os.makedirs(helpers)
cdir = dirname(helpers)
@@ -421,7 +418,8 @@ class Freeze:
LSMinimumSystemVersion=MINIMUM_SYSTEM_VERSION,
LSRequiresNativeExecution=True,
NSAppleScriptEnabled=False,
CFBundleIconFile='',
CFBundleIconFile='book.icns',
CFBundleIconName='book',
)
with open(join(cdir, 'Info.plist'), 'wb') as p:
plistlib.dump(pl, p)
@@ -475,6 +473,7 @@ class Freeze:
NSHumanReadableCopyright=time.strftime('Copyright %Y, Kovid Goyal'),
CFBundleGetInfoString=('calibre, an E-book management '
'application. Visit https://calibre-ebook.com for details.'),
CFBundleIconName='calibre',
CFBundleIconFile='calibre.icns',
NSHighResolutionCapable=True,
LSApplicationCategoryType='public.app-category.productivity',
@@ -778,6 +777,7 @@ class Freeze:
}[launcher]
plist['CFBundleExecutable'] = launcher
plist['CFBundleIdentifier'] = 'com.calibre-ebook.' + launcher
plist['CFBundleIconName'] = launcher
plist['CFBundleIconFile'] = launcher + '.icns'
e = plist['CFBundleDocumentTypes'][0]
e['CFBundleTypeExtensions'] = [x.lower() for x in formats]
+126 -44
View File
@@ -4,20 +4,77 @@
__license__ = 'GPL v3'
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
import json
import os
import shutil
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
from copy import deepcopy
d, j, a = (getattr(os.path, x) for x in ('dirname', 'join', 'abspath'))
base = d(a(__file__))
os.chdir(base)
# To generate this template create an icon using Icon Composer on macOS and in
# the saved .icon (which is a folder) look for icon.js
icon_settings = {
'fill-specializations' : [
{
'value' : {
'automatic-gradient' : 'extended-gray:1.00000,1.00000'
}
},
{
'appearance' : 'dark',
'value' : {
'automatic-gradient' : 'display-p3:0.20500,0.20500,0.20500,1.00000'
}
}
],
'groups' : [
{
'layers' : [
{
'blend-mode' : 'normal',
'glass' : False,
'hidden' : False,
'image-name' : 'icon.svg',
'name' : 'icon',
'opacity' : 1,
'position' : {
'scale' : 0.9,
'translation-in-points' : [
0,
0
]
}
}
],
'shadow' : {
'kind' : 'neutral',
'opacity' : 0.5
},
'translucency' : {
'enabled' : True,
'value' : 0.5
}
}
],
'supported-platforms' : {
'circles' : [
'watchOS'
],
'squares' : 'shared'
}
}
imgsrc = j(d(d(base)), 'imgsrc')
sources = {'calibre':j(imgsrc, 'calibre.svg'), 'ebook-edit':j(imgsrc, 'tweak.svg'), 'ebook-viewer':j(imgsrc, 'viewer.svg'), 'book':j(imgsrc, 'book.svg')}
if sys.argv[-1] == 'only-logo':
sources = {'calibre':sources['calibre']}
sources = {
'calibre':j(imgsrc, 'calibre.svg'),
'ebook-edit':j(imgsrc, 'tweak.svg'),
'ebook-viewer':j(imgsrc, 'viewer.svg'),
'book':j(imgsrc, 'book.svg')
}
frames = {
'light': j(imgsrc, 'frame.svg'),
@@ -25,47 +82,72 @@ frames = {
}
def render_svg(src, sz, dest):
subprocess.check_call(['rsvg-convert', src, '-w', str(sz), '-h', str(sz), '-o', dest])
def get_svg_viewbox(file_path: str) -> tuple[float, ...]:
tree = ET.parse(file_path)
root = tree.getroot()
viewbox = root.get('viewBox')
if viewbox:
return tuple(float(x) for x in viewbox.split())
width = root.get('width')
height = root.get('height')
return [0.0, 0.0, float(width or 0), float(height or 0)]
with tempfile.TemporaryDirectory() as tdir:
def create_icon(name: str, svg_path: str, output_path: str) -> str:
view_box = get_svg_viewbox(svg_path)
sz = view_box[-1]
scale = 0.9 * 1024 / sz
icon_dir = os.path.join(output_path, f'{name}.icon')
if os.path.exists(icon_dir):
shutil.rmtree(icon_dir)
os.mkdir(icon_dir)
s = deepcopy(icon_settings)
for group in s['groups']:
for layer in group['layers']:
layer['image-name'] = os.path.basename(svg_path)
layer['name'] = name
layer['position']['scale'] = scale
with open(os.path.join(icon_dir, 'icon.json'), 'w') as f:
json.dump(s, f, indent=2)
assets_dir = os.path.join(icon_dir, 'Assets')
os.mkdir(assets_dir)
shutil.copy(svg_path, assets_dir)
return icon_dir
def render_frame(mode: str, sz: int):
f = os.path.join(tdir, f'frame-{mode}-{sz}.png')
if not os.path.exists(f):
render_svg(frames[mode], sz, f)
return f
def render_framed(mode: str, sz: int, iname: str, shrink_factor: float = 0.75):
frame = render_frame(mode, sz)
icon = os.path.join(tdir, f'icon-{sz}.png')
render_svg(src, int(shrink_factor * sz), icon)
subprocess.check_call(f'magick {frame} {icon} -gravity center -compose over -composite {iname}'.split())
def create_assets():
os.chdir(base)
actool = [
'xcrun', 'actool', '--warnings', '--platform', 'macosx', '--compile', '.',
'--minimum-deployment-target', '15.0', '--output-partial-info-plist', '/dev/stdout',
]
primary = ''
icons = []
alternates = []
for name, svg in sources.items():
if not primary:
primary = name
icons.append(create_icon(name, svg, os.getcwd()))
if name != primary:
alternates.extend(('--alternate-app-icon', name))
# Generate .icns for backwards compat
subprocess.check_call(actool + ['--app-icon', name, icons[-1]])
os.remove('Assets.car')
subprocess.check_call([
'xcrun', 'actool', '--warnings', '--platform', 'macosx', '--compile', '.',
'--minimum-deployment-target', '15.0', '--output-partial-info-plist', '/dev/stdout',
'--app-icon', primary] + alternates + icons)
for x in icons:
shutil.rmtree(x)
for mode in ('light', 'dark'):
subdir = mode
os.makedirs(subdir, exist_ok=True)
for name, src in sources.items():
iconset = j(subdir, name + '.iconset')
if os.path.exists(iconset):
shutil.rmtree(iconset)
os.mkdir(iconset)
os.chdir(iconset)
try:
for sz in (16, 32, 64, 128, 256, 512, 1024):
iname = f'icon_{sz}x{sz}.png'
iname2x = f'icon_{sz // 2}x{sz // 2}@2x.png'
if sz < 128:
render_svg(src, sz, iname)
else:
render_framed(mode, sz, iname)
subprocess.check_call(['optipng', '-o7', '-strip', 'all', iname])
if sz > 16:
shutil.copy2(iname, iname2x)
if sz > 512 or sz == 64:
os.remove(iname)
if sz == 128:
os.remove(iname2x)
finally:
os.chdir('../..')
def main():
if 'darwin' in sys.platform.lower():
create_assets()
else:
subprocess.check_call(['ssh', 'ox', 'sh', '-c', '~/bin/update-calibre && python3 ~/calibre-src/icons/icns/make_iconsets.py'])
subprocess.check_call(['rsync', '-avz', '--include=*.icns', '--include=*.car', '--exclude=*', 'ox:~/calibre-src/icons/icns/', base + '/'])
if __name__ == '__main__':
main()
-84
View File
@@ -1,84 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="1024"
height="1024"
fill="none"
version="1.1"
id="svg13"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<g
filter="url(#a)"
id="g2">
<rect
width="824"
height="824"
x="100"
y="100"
fill="#1c1c1e"
rx="184"
id="rect1" />
<rect
width="824"
height="824"
x="100"
y="100"
fill="url(#b)"
rx="184"
id="rect2" />
</g>
<defs
id="defs13">
<linearGradient
id="b"
x1="512"
x2="512"
y1="100"
y2="924"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#fff"
stop-opacity=".08"
id="stop10" />
<stop
offset="1"
stop-opacity="0"
id="stop11" />
</linearGradient>
<filter
id="a"
width="868"
height="868"
x="78"
y="89"
color-interpolation-filters="sRGB"
filterUnits="userSpaceOnUse">
<feFlood
flood-opacity="0"
result="BackgroundImageFix"
id="feFlood11" />
<feColorMatrix
in="SourceAlpha"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
id="feColorMatrix11" />
<feOffset
dy="11"
id="feOffset11" />
<feGaussianBlur
stdDeviation="11"
id="feGaussianBlur11" />
<feColorMatrix
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0"
id="feColorMatrix12" />
<feBlend
in2="BackgroundImageFix"
result="effect1_dropShadow"
id="feBlend12" />
<feBlend
in="SourceGraphic"
in2="effect1_dropShadow"
result="shape"
id="feBlend13" />
</filter>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

-83
View File
@@ -1,83 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="1024"
height="1024"
fill="none"
version="1.1"
id="svg13"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<g
filter="url(#a)"
id="g2">
<rect
width="824"
height="824"
x="100"
y="100"
fill="#fff"
rx="184"
id="rect1" />
<rect
width="824"
height="824"
x="100"
y="100"
fill="url(#b)"
rx="184"
id="rect2" />
</g>
<defs
id="defs13">
<linearGradient
id="b"
x1="512"
x2="512"
y1="100"
y2="924"
gradientUnits="userSpaceOnUse">
<stop
stop-opacity="0"
id="stop10" />
<stop
offset="1"
stop-opacity=".2"
id="stop11" />
</linearGradient>
<filter
id="a"
width="868"
height="868"
x="78"
y="89"
color-interpolation-filters="sRGB"
filterUnits="userSpaceOnUse">
<feFlood
flood-opacity="0"
result="BackgroundImageFix"
id="feFlood11" />
<feColorMatrix
in="SourceAlpha"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
id="feColorMatrix11" />
<feOffset
dy="11"
id="feOffset11" />
<feGaussianBlur
stdDeviation="11"
id="feGaussianBlur11" />
<feColorMatrix
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.28 0"
id="feColorMatrix12" />
<feBlend
in2="BackgroundImageFix"
result="effect1_dropShadow"
id="feBlend12" />
<feBlend
in="SourceGraphic"
in2="effect1_dropShadow"
result="shape"
id="feBlend13" />
</filter>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

+2 -1
View File
@@ -36,9 +36,10 @@ subprocess.check_call([sys.executable, j(icons, 'make_ico_files.py'), 'only-logo
shutil.copy2(j(icons, 'library.ico'), j(srv, 'common', 'favicon.ico'))
shutil.copy2(j(icons, 'library.ico'), j(srv, 'main/static/resources/img', 'favicon.ico'))
shutil.copy2(j(icons, 'library.ico'), j(srv, 'open-books/drmfree/static/img', 'favicon.ico'))
subprocess.check_call([sys.executable, j(icons, 'icns', 'make_iconsets.py'), 'only-logo'])
os.chdir(srv)
subprocess.check_call(['git', 'commit', '-am', 'Update calibre favicons'])
for s in 'main code open-books dl1'.split():
subprocess.check_call(['./publish', s, 'update'])
subprocess.check_call([sys.executable, j(icons, 'icns', 'make_iconsets.py')])