Skip to content

Commit 864aaa7

Browse files
Add a real app icon to the bundle
The release bundle carried no icon file at all, so macOS fell back to its generic blank app placeholder in Finder, the Dock, and the DMG window. The icon is generated by `scripts/make-app-icon.swift` rather than checked in as hand-drawn art, so it stays in step with the palette: the brass and charcoal are the same values Theme.Palette uses for the patro skin. All ten iconset variants are drawn at their own resolution instead of downscaled from 1024 — the 16pt icon is mostly spine, and a downscaled version of the large art turns to mud at that size. The mark is a patro tile: a brass spine with a hanging hole, and स — the first letter of सजिलो — set in Kohinoor Devanagari, the face macOS itself uses for Devanagari. A monogram rather than a flag or a generic calendar page. Both bundle scripts now copy the .icns into Contents/Resources, and CFBundleVersion moves to 2 for the next beta. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 7b6fbad commit 864aaa7

7 files changed

Lines changed: 291 additions & 1 deletion

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Package.resolved
1919
# Local tooling and generated artifacts
2020
build/
2121
documents/
22+
scripts/AppIcon.iconset/
2223

2324
# Local configuration and secrets
2425
.env

RELEASING.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,19 @@ GitHub Actions builds and publishes the Mac download for you.
99
3. Change `CFBundleVersion` to a higher number each release.
1010
4. Commit and push your changes to GitHub.
1111

12+
## Changing the app icon
13+
14+
The icon is generated, not a checked-in drawing. Edit `scripts/make-app-icon.swift`
15+
and run:
16+
17+
```bash
18+
swift scripts/make-app-icon.swift
19+
```
20+
21+
That rewrites `scripts/AppIcon.icns`, which both bundle scripts copy into
22+
`Contents/Resources`. Commit the `.icns`; the `AppIcon.iconset` folder beside it
23+
is an intermediate and is ignored.
24+
1225
## Publish a beta
1326

1427
For the first beta of version `0.1.0`, create and push this tag:

scripts/AppBundleInfo.plist

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
<string>en</string>
77
<key>CFBundleExecutable</key>
88
<string>Sajilo</string>
9+
<key>CFBundleIconFile</key>
10+
<string>AppIcon</string>
11+
<key>CFBundleIconName</key>
12+
<string>AppIcon</string>
913
<key>CFBundleIdentifier</key>
1014
<string>com.sajilo.desktop</string>
1115
<key>CFBundleInfoDictionaryVersion</key>
@@ -17,7 +21,7 @@
1721
<key>CFBundleShortVersionString</key>
1822
<string>0.1.0</string>
1923
<key>CFBundleVersion</key>
20-
<string>1</string>
24+
<string>2</string>
2125
<key>LSUIElement</key>
2226
<true/>
2327
<key>NSHighResolutionCapable</key>

scripts/AppIcon.icns

429 KB
Binary file not shown.

scripts/make-app-icon.swift

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
#!/usr/bin/env swift
2+
//
3+
// Draws Sajilo's app icon and writes scripts/AppIcon.icns.
4+
//
5+
// The icon is generated rather than hand-drawn so it stays in step with the
6+
// palette: the brass and charcoal below are the same values Theme.Palette uses
7+
// for the patro skin, and changing them there means re-running this.
8+
//
9+
// swift scripts/make-app-icon.swift
10+
//
11+
// Every size is drawn at its own resolution rather than downscaled from 1024.
12+
// The 16pt icon is two thirds spine and one third glyph — at that size a
13+
// downscaled version of the large art turns to mud.
14+
15+
import AppKit
16+
import CoreText
17+
18+
// MARK: - Palette
19+
20+
/// Warm charcoal, tinted toward the brass rather than a neutral grey, so the
21+
/// two colours read as one material.
22+
let inkTop = NSColor(srgbRed: 0.153, green: 0.141, blue: 0.125, alpha: 1)
23+
let inkBottom = NSColor(srgbRed: 0.086, green: 0.078, blue: 0.067, alpha: 1)
24+
25+
/// The `patro` brand brass, lit from above as a real gilt surface would be.
26+
let brassLight = NSColor(srgbRed: 0.831, green: 0.686, blue: 0.384, alpha: 1)
27+
let brassMid = NSColor(srgbRed: 0.690, green: 0.549, blue: 0.247, alpha: 1)
28+
let brassDeep = NSColor(srgbRed: 0.478, green: 0.365, blue: 0.141, alpha: 1)
29+
30+
// MARK: - Drawing
31+
32+
func drawIcon(side: CGFloat, into context: CGContext) {
33+
let scale = side / 1024
34+
35+
context.setFillColor(NSColor.clear.cgColor)
36+
context.fill(CGRect(x: 0, y: 0, width: side, height: side))
37+
38+
// Apple's macOS grid: an 824pt body on a 1024pt canvas, so the icon sits
39+
// at the same visual weight as every system app beside it in the Dock.
40+
let body = CGRect(x: 100 * scale, y: 108 * scale, width: 824 * scale, height: 824 * scale)
41+
let radius = 185.4 * scale
42+
let shape = CGPath(roundedRect: body, cornerWidth: radius, cornerHeight: radius, transform: nil)
43+
44+
// Contact shadow, so the tile sits on the desktop instead of floating.
45+
context.saveGState()
46+
context.setShadow(
47+
offset: CGSize(width: 0, height: -10 * scale),
48+
blur: 22 * scale,
49+
color: NSColor(white: 0, alpha: 0.34).cgColor
50+
)
51+
context.addPath(shape)
52+
context.setFillColor(inkBottom.cgColor)
53+
context.fillPath()
54+
context.restoreGState()
55+
56+
context.saveGState()
57+
context.addPath(shape)
58+
context.clip()
59+
60+
fillVertical(context, rect: body, from: inkTop, to: inkBottom)
61+
62+
// The spine. A patro hangs from a printed band, and it is the one element
63+
// that still reads as a calendar at 16pt, where the glyph is four pixels
64+
// tall. Proportionally deeper at small sizes for exactly that reason.
65+
let spineFraction: CGFloat = side <= 32 ? 0.34 : 0.25
66+
let spine = CGRect(
67+
x: body.minX,
68+
y: body.maxY - body.height * spineFraction,
69+
width: body.width,
70+
height: body.height * spineFraction
71+
)
72+
fillVertical(context, rect: spine, from: brassLight, to: brassMid)
73+
74+
// A single hanging hole punched out of the spine, in the tile's own ink so
75+
// it reads as a hole rather than a dot. Dropped below 64pt, where it would
76+
// close up into a smudge.
77+
if side >= 64 {
78+
let holeSide = spine.height * 0.26
79+
let hole = CGRect(
80+
x: body.midX - holeSide / 2,
81+
y: spine.midY - holeSide / 2,
82+
width: holeSide,
83+
height: holeSide
84+
)
85+
context.setFillColor(inkTop.cgColor)
86+
context.fillEllipse(in: hole)
87+
}
88+
89+
drawGlyph(in: body, below: spine, context: context, side: side)
90+
91+
context.restoreGState()
92+
93+
// Hairline edge, so the tile keeps its shape against a dark wallpaper.
94+
context.addPath(shape)
95+
context.setStrokeColor(NSColor(white: 1, alpha: 0.10).cgColor)
96+
context.setLineWidth(max(1, 2 * scale))
97+
context.strokePath()
98+
}
99+
100+
/// स — the first letter of सजिलो, and a monogram rather than a flag or a
101+
/// generic calendar page. It carries the Devanagari headline across its top,
102+
/// which echoes the spine above it.
103+
func drawGlyph(in body: CGRect, below spine: CGRect, context: CGContext, side: CGFloat) {
104+
let field = CGRect(
105+
x: body.minX,
106+
y: body.minY,
107+
width: body.width,
108+
height: spine.minY - body.minY
109+
)
110+
111+
// Chunkier at small sizes: below 32pt the letter is a handful of pixels
112+
// and needs the extra weight, where at 512 the same ratio crowds the tile.
113+
let fill: CGFloat = side <= 32 ? 0.78 : 0.66
114+
let font = devanagariFont(ofSize: field.height * fill)
115+
let attributed = NSAttributedString(
116+
string: "",
117+
attributes: [.font: font, .foregroundColor: brassLight]
118+
)
119+
120+
let line = CTLineCreateWithAttributedString(attributed)
121+
let bounds = CTLineGetBoundsWithOptions(line, .useGlyphPathBounds)
122+
123+
context.saveGState()
124+
context.textMatrix = .identity
125+
// Nudged above the geometric centre. Devanagari hangs from its headline
126+
// rather than sitting on a baseline, so a mathematically centred glyph
127+
// reads as having sunk.
128+
context.textPosition = CGPoint(
129+
x: field.midX - bounds.midX,
130+
y: field.midY - bounds.midY + field.height * 0.04
131+
)
132+
133+
// Filled from the glyph's own path so the gilt gradient runs through the
134+
// letter rather than sitting behind a flat fill.
135+
if let path = glyphPath(from: line, at: context.textPosition) {
136+
context.addPath(path)
137+
context.clip()
138+
fillVertical(context, rect: bounds.offsetBy(dx: context.textPosition.x, dy: context.textPosition.y),
139+
from: brassLight, to: brassDeep)
140+
} else {
141+
CTLineDraw(line, context)
142+
}
143+
context.restoreGState()
144+
}
145+
146+
func glyphPath(from line: CTLine, at origin: CGPoint) -> CGPath? {
147+
let combined = CGMutablePath()
148+
for run in CTLineGetGlyphRuns(line) as! [CTRun] {
149+
let attributes = CTRunGetAttributes(run) as NSDictionary
150+
guard let font = attributes[kCTFontAttributeName as String] else { continue }
151+
let runFont = font as! CTFont
152+
153+
let count = CTRunGetGlyphCount(run)
154+
var glyphs = [CGGlyph](repeating: 0, count: count)
155+
var positions = [CGPoint](repeating: .zero, count: count)
156+
CTRunGetGlyphs(run, CFRangeMake(0, count), &glyphs)
157+
CTRunGetPositions(run, CFRangeMake(0, count), &positions)
158+
159+
for index in 0..<count {
160+
guard let letter = CTFontCreatePathForGlyph(runFont, glyphs[index], nil) else { continue }
161+
let transform = CGAffineTransform(
162+
translationX: origin.x + positions[index].x,
163+
y: origin.y + positions[index].y
164+
)
165+
combined.addPath(letter, transform: transform)
166+
}
167+
}
168+
return combined.isEmpty ? nil : combined
169+
}
170+
171+
/// Kohinoor Devanagari is the face macOS itself sets Devanagari in, so the
172+
/// icon matches the app's own text rather than introducing a second voice.
173+
func devanagariFont(ofSize size: CGFloat) -> NSFont {
174+
for name in ["KohinoorDevanagari-Semibold", "KohinoorDevanagari-Medium", "DevanagariMT-Bold"] {
175+
if let font = NSFont(name: name, size: size) { return font }
176+
}
177+
return NSFont.systemFont(ofSize: size, weight: .semibold)
178+
}
179+
180+
func fillVertical(_ context: CGContext, rect: CGRect, from top: NSColor, to bottom: NSColor) {
181+
guard let gradient = CGGradient(
182+
colorsSpace: CGColorSpaceCreateDeviceRGB(),
183+
colors: [bottom.cgColor, top.cgColor] as CFArray,
184+
locations: [0, 1]
185+
) else { return }
186+
187+
context.saveGState()
188+
context.clip(to: rect)
189+
context.drawLinearGradient(
190+
gradient,
191+
start: CGPoint(x: rect.midX, y: rect.minY),
192+
end: CGPoint(x: rect.midX, y: rect.maxY),
193+
options: [.drawsBeforeStartLocation, .drawsAfterEndLocation]
194+
)
195+
context.restoreGState()
196+
}
197+
198+
// MARK: - Output
199+
200+
func render(side: Int) throws -> Data {
201+
guard let rep = NSBitmapImageRep(
202+
bitmapDataPlanes: nil,
203+
pixelsWide: side, pixelsHigh: side,
204+
bitsPerSample: 8, samplesPerPixel: 4,
205+
hasAlpha: true, isPlanar: false,
206+
colorSpaceName: .deviceRGB,
207+
bytesPerRow: 0, bitsPerPixel: 0
208+
) else { throw Failure("could not allocate a \(side)px bitmap") }
209+
210+
NSGraphicsContext.saveGraphicsState()
211+
defer { NSGraphicsContext.restoreGraphicsState() }
212+
guard let graphics = NSGraphicsContext(bitmapImageRep: rep) else {
213+
throw Failure("could not bind a drawing context to the bitmap")
214+
}
215+
NSGraphicsContext.current = graphics
216+
drawIcon(side: CGFloat(side), into: graphics.cgContext)
217+
graphics.flushGraphics()
218+
219+
guard let data = rep.representation(using: .png, properties: [:]) else {
220+
throw Failure("could not encode the \(side)px bitmap as PNG")
221+
}
222+
return data
223+
}
224+
225+
struct Failure: Error, CustomStringConvertible {
226+
let description: String
227+
init(_ description: String) { self.description = description }
228+
}
229+
230+
let scriptsDirectory = URL(fileURLWithPath: CommandLine.arguments[0])
231+
.deletingLastPathComponent()
232+
let iconset = scriptsDirectory.appendingPathComponent("AppIcon.iconset")
233+
let icns = scriptsDirectory.appendingPathComponent("AppIcon.icns")
234+
235+
try? FileManager.default.removeItem(at: iconset)
236+
try FileManager.default.createDirectory(at: iconset, withIntermediateDirectories: true)
237+
238+
// The ten variants `iconutil` expects. Missing one leaves macOS interpolating
239+
// that size from another, which is what makes a hand-made icon look soft.
240+
let variants: [(name: String, side: Int)] = [
241+
("icon_16x16", 16), ("icon_16x16@2x", 32),
242+
("icon_32x32", 32), ("icon_32x32@2x", 64),
243+
("icon_128x128", 128), ("icon_128x128@2x", 256),
244+
("icon_256x256", 256), ("icon_256x256@2x", 512),
245+
("icon_512x512", 512), ("icon_512x512@2x", 1024),
246+
]
247+
248+
for variant in variants {
249+
let data = try render(side: variant.side)
250+
try data.write(to: iconset.appendingPathComponent("\(variant.name).png"))
251+
}
252+
253+
let iconutil = Process()
254+
iconutil.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil")
255+
iconutil.arguments = ["--convert", "icns", iconset.path, "--output", icns.path]
256+
try iconutil.run()
257+
iconutil.waitUntilExit()
258+
guard iconutil.terminationStatus == 0 else {
259+
throw Failure("iconutil exited \(iconutil.terminationStatus)")
260+
}
261+
262+
print("Wrote \(icns.path)")

scripts/package-release.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ mkdir -p "$APP_PATH/Contents/Frameworks"
3131
mkdir -p "$APP_PATH/Contents/Resources"
3232
cp "$PROJECT_ROOT/scripts/AppBundleInfo.plist" "$APP_PATH/Contents/Info.plist"
3333

34+
# The icon. Without it macOS falls back to its generic blank app placeholder,
35+
# which is what ships if this line is ever dropped. Regenerate with
36+
# `swift scripts/make-app-icon.swift` after changing the palette.
37+
cp "$PROJECT_ROOT/scripts/AppIcon.icns" "$APP_PATH/Contents/Resources/AppIcon.icns"
38+
3439
# SwiftPM builds an executable, not an application bundle. Assemble the bundle
3540
# exactly as the local launcher does, then ad-hoc sign it for direct download.
3641
cp "$EXECUTABLE_PATH" "$APP_PATH/Contents/MacOS/Sajilo"

scripts/run-local-app.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ mkdir -p "$APP_PATH/Contents/Frameworks"
2121
mkdir -p "$APP_PATH/Contents/Resources"
2222
cp "$PROJECT_ROOT/scripts/AppBundleInfo.plist" "$APP_PATH/Contents/Info.plist"
2323

24+
# The icon. Without it macOS falls back to its generic blank app placeholder,
25+
# which is what ships if this line is ever dropped. Regenerate with
26+
# `swift scripts/make-app-icon.swift` after changing the palette.
27+
cp "$PROJECT_ROOT/scripts/AppIcon.icns" "$APP_PATH/Contents/Resources/AppIcon.icns"
28+
2429
# SwiftPM's executable is not linked as part of an Xcode app target, so add the
2530
# conventional app-bundle framework search path that Xcode would normally set.
2631
# Patch it before placing it inside the bundle. The completed app is signed

0 commit comments

Comments
 (0)