diff --git a/icons/README.md b/icons/README.md new file mode 100644 index 0000000..2ac0e8a --- /dev/null +++ b/icons/README.md @@ -0,0 +1,28 @@ +# Icons + +Every file here is generated, not hand drawn. `packaging/make-icon.swift` is the source: + +```sh +xcrun swiftc -O -o /tmp/make-icon packaging/make-icon.swift +/tmp/make-icon icons --marketing # the PNGs below +/tmp/make-icon icons --social # social-preview.png +``` + +The app's own `.icns` is built from the same file by `scripts/build-app.sh`, so the icon on disk and +the icon in the app can never disagree. + +| File | Use it for | +| --- | --- | +| `icon-16.png` to `icon-48.png` | favicons | +| `icon-128.png`, `icon-180.png`, `icon-256.png` | a README header, docs, a touch icon | +| `icon-512.png`, `icon-1024.png` | store listings, anything print or retina | +| `icon-square-400.png`, `-512.png`, `-1024.png` | avatars: GitHub, X, Discord, anywhere the picture is cropped | +| `social-preview.png` | GitHub's repository social preview, 1280x640 | +| `icon.svg` | vector, for any size that is not here | + +The square files fill the frame instead of rounding their own corners, because an avatar is cropped +again by whoever shows it, and a picture that rounds itself first loses a ring of artwork to the +second rounding. Everywhere else, use the rounded ones. + +The SVG is a close copy rather than an exact one: its gradient renders slightly lighter than the +PNGs, which are what the app ships. Where the two would sit side by side, use a PNG. diff --git a/icons/icon-1024.png b/icons/icon-1024.png new file mode 100644 index 0000000..bef3013 Binary files /dev/null and b/icons/icon-1024.png differ diff --git a/icons/icon-128.png b/icons/icon-128.png new file mode 100644 index 0000000..d3aebbc Binary files /dev/null and b/icons/icon-128.png differ diff --git a/icons/icon-16.png b/icons/icon-16.png new file mode 100644 index 0000000..a2b714e Binary files /dev/null and b/icons/icon-16.png differ diff --git a/icons/icon-180.png b/icons/icon-180.png new file mode 100644 index 0000000..5e4cb89 Binary files /dev/null and b/icons/icon-180.png differ diff --git a/icons/icon-256.png b/icons/icon-256.png new file mode 100644 index 0000000..1d916b0 Binary files /dev/null and b/icons/icon-256.png differ diff --git a/icons/icon-32.png b/icons/icon-32.png new file mode 100644 index 0000000..915f8cd Binary files /dev/null and b/icons/icon-32.png differ diff --git a/icons/icon-48.png b/icons/icon-48.png new file mode 100644 index 0000000..f405177 Binary files /dev/null and b/icons/icon-48.png differ diff --git a/icons/icon-512.png b/icons/icon-512.png new file mode 100644 index 0000000..693c62b Binary files /dev/null and b/icons/icon-512.png differ diff --git a/icons/icon-64.png b/icons/icon-64.png new file mode 100644 index 0000000..5a91df6 Binary files /dev/null and b/icons/icon-64.png differ diff --git a/icons/icon-square-1024.png b/icons/icon-square-1024.png new file mode 100644 index 0000000..fc7018b Binary files /dev/null and b/icons/icon-square-1024.png differ diff --git a/icons/icon-square-400.png b/icons/icon-square-400.png new file mode 100644 index 0000000..f13f9df Binary files /dev/null and b/icons/icon-square-400.png differ diff --git a/icons/icon-square-512.png b/icons/icon-square-512.png new file mode 100644 index 0000000..98d8bce Binary files /dev/null and b/icons/icon-square-512.png differ diff --git a/icons/icon.svg b/icons/icon.svg new file mode 100644 index 0000000..72a04c0 --- /dev/null +++ b/icons/icon.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icons/social-preview.png b/icons/social-preview.png new file mode 100644 index 0000000..aa28261 Binary files /dev/null and b/icons/social-preview.png differ diff --git a/packaging/make-icon.swift b/packaging/make-icon.swift index b1a0bdd..ff272b2 100644 --- a/packaging/make-icon.swift +++ b/packaging/make-icon.swift @@ -1,64 +1,251 @@ import AppKit -// Draws the placeholder app icon: a rounded square with a device outline on it. Deliberately plain, -// and meant to be replaced by real artwork before anyone sees it. -func drawIcon(size: CGFloat) -> NSImage { - let image = NSImage(size: NSSize(width: size, height: size)) - image.lockFocus() - defer { image.unlockFocus() } - - let unit = size / 1024 - let plate = NSBezierPath( - roundedRect: NSRect(x: 80 * unit, y: 80 * unit, width: 864 * unit, height: 864 * unit), - xRadius: 190 * unit, - yRadius: 190 * unit - ) - NSGradient( - starting: NSColor(calibratedRed: 0.24, green: 0.25, blue: 0.86, alpha: 1), - ending: NSColor(calibratedRed: 0.11, green: 0.12, blue: 0.45, alpha: 1) - )?.draw(in: plate, angle: -90) +// The app icon and the social banner, drawn in code so every size comes from one description rather +// than from exported files that drift: two device windows, one in front of the other, which is what +// this app does that the tool it replaces does not. +let plateGradient = NSGradient( + starting: NSColor(calibratedRed: 0.24, green: 0.25, blue: 0.86, alpha: 1), + ending: NSColor(calibratedRed: 0.11, green: 0.12, blue: 0.45, alpha: 1) +)! + +// The two device outlines, in a 1024 unit design space placed by `unit` and `origin`, so the icon +// and the social banner draw the same artwork rather than two copies of it that drift. +// +// `paintBackground` is what the front device cuts back to. It has to be passed in because the icon +// sits on a rounded plate and the banner on a full bleed gradient. +func drawDevices(unit: CGFloat, origin: CGPoint, paintBackground: () -> Void) { + func rect(_ x: CGFloat, _ y: CGFloat, _ width: CGFloat, _ height: CGFloat) -> NSRect { + NSRect( + x: origin.x + x * unit, + y: origin.y + y * unit, + width: width * unit, + height: height * unit + ) + } + + func device( + x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat, + line: CGFloat, alpha: CGFloat + ) { + let body = NSBezierPath( + roundedRect: rect(x, y, width, height), + xRadius: 54 * unit, + yRadius: 54 * unit + ) + body.lineWidth = line * unit + NSColor.white.withAlphaComponent(alpha).setStroke() + body.stroke() + + let island = NSBezierPath( + roundedRect: rect(x + 96, y + height - 74, 100, 30), + xRadius: 15 * unit, + yRadius: 15 * unit + ) + NSColor.white.withAlphaComponent(alpha).setFill() + island.fill() - let body = NSBezierPath( - roundedRect: NSRect(x: 366 * unit, y: 238 * unit, width: 292 * unit, height: 548 * unit), + let indicator = NSBezierPath( + roundedRect: rect(x + 96, y + 40, 100, 14), + xRadius: 7 * unit, + yRadius: 7 * unit + ) + NSColor.white.withAlphaComponent(0.8 * alpha).setFill() + indicator.fill() + } + + device(x: 286, y: 330, width: 268, height: 500, line: 24, alpha: 0.75) + + // The front device cuts back to the background before it is drawn. Without that the two outlines + // cross and it reads as a tangle rather than as one window in front of another, which is the + // whole point of it and the first thing to go at small sizes. + let front = NSBezierPath( + roundedRect: rect(452, 214, 292, 548), xRadius: 54 * unit, yRadius: 54 * unit ) - body.lineWidth = 26 * unit - NSColor.white.setStroke() - body.stroke() - - let island = NSBezierPath( - roundedRect: NSRect(x: 462 * unit, y: 712 * unit, width: 100 * unit, height: 30 * unit), - xRadius: 15 * unit, - yRadius: 15 * unit + NSGraphicsContext.saveGraphicsState() + front.addClip() + paintBackground() + NSGraphicsContext.restoreGraphicsState() + + device(x: 452, y: 214, width: 292, height: 548, line: 26, alpha: 1) +} + +// `edgeToEdge` drops the rounded plate and the transparent margin around it, filling the square +// instead. Avatars are cropped to a circle or a rounded square by whoever displays them, so a +// picture that rounds its own corners first loses a ring of artwork to the second rounding. +func drawIcon(size: CGFloat, edgeToEdge: Bool = false) { + // Edge to edge scales the artwork so the plate fills the canvas, keeping every proportion inside + // it, rather than drawing something different. + let unit = size / (edgeToEdge ? 864 : 1024) + let inset = edgeToEdge ? 80 * unit : 0 + let plate = NSBezierPath( + roundedRect: NSRect( + x: 80 * unit - inset, + y: 80 * unit - inset, + width: 864 * unit, + height: 864 * unit + ), + xRadius: edgeToEdge ? 0 : 190 * unit, + yRadius: edgeToEdge ? 0 : 190 * unit + ) + func paintPlate() { plateGradient.draw(in: plate, angle: -90) } + paintPlate() + drawDevices( + unit: unit, + origin: CGPoint(x: -inset, y: -inset), + paintBackground: paintPlate ) - NSColor.white.setFill() - island.fill() +} - let indicator = NSBezierPath( - roundedRect: NSRect(x: 462 * unit, y: 278 * unit, width: 100 * unit, height: 14 * unit), - xRadius: 7 * unit, - yRadius: 7 * unit +// Drawn into a bitmap of exactly the asked for pixel size. Going through `NSImage.lockFocus` instead +// hands back the main display's backing scale, so on a Retina Mac every file came out at twice the +// size its name promised. +func png(size: Int, edgeToEdge: Bool = false) -> Data { + guard let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: size, + pixelsHigh: size, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + ) else { + print("could not make a \(size) bitmap") + exit(1) + } + bitmap.size = NSSize(width: size, height: size) + + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmap) + drawIcon(size: CGFloat(size), edgeToEdge: edgeToEdge) + NSGraphicsContext.restoreGraphicsState() + + guard let data = bitmap.representation(using: .png, properties: [:]) else { + print("could not encode \(size)") + exit(1) + } + return data +} + +// GitHub asks for at least 640x320 and shows 1280x640 best. It also crops the edges in some places, +// so everything that matters stays well inside the frame. +func socialPreview(width: Int = 1280, height: Int = 640) -> Data { + guard let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: width, + pixelsHigh: height, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + ) else { + print("could not make the banner bitmap") + exit(1) + } + bitmap.size = NSSize(width: width, height: width == 0 ? 0 : height) + + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmap) + + let frame = NSRect(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height)) + func paintBackground() { + plateGradient.draw(in: NSBezierPath(rect: frame), angle: -90) + } + paintBackground() + + // The artwork is 1024 units wide whatever it is drawn at, so it is placed by its centre. + let artwork: CGFloat = 470 + let unit = artwork / 1024 + drawDevices( + unit: unit, + origin: CGPoint(x: 245 - 512 * unit, y: CGFloat(height) / 2 - 512 * unit), + paintBackground: paintBackground ) - NSColor.white.withAlphaComponent(0.8).setFill() - indicator.fill() - return image + let left: CGFloat = 470 + let title = "OpenDeviceHub" + let tagline = "Your iOS simulators in windows again, one per device." + let footnote = "Open source, MIT licensed. For Xcode 26 and 27 on macOS 14 or later." + + title.draw( + at: NSPoint(x: left, y: CGFloat(height) / 2 + 16), + withAttributes: [ + .font: NSFont.systemFont(ofSize: 76, weight: .bold), + .foregroundColor: NSColor.white, + ] + ) + tagline.draw( + at: NSPoint(x: left + 4, y: CGFloat(height) / 2 - 44), + withAttributes: [ + .font: NSFont.systemFont(ofSize: 31, weight: .regular), + .foregroundColor: NSColor.white.withAlphaComponent(0.92), + ] + ) + footnote.draw( + at: NSPoint(x: left + 4, y: CGFloat(height) / 2 - 100), + withAttributes: [ + .font: NSFont.systemFont(ofSize: 23, weight: .regular), + .foregroundColor: NSColor.white.withAlphaComponent(0.66), + ] + ) + + NSGraphicsContext.restoreGraphicsState() + + guard let data = bitmap.representation(using: .png, properties: [:]) else { + print("could not encode the banner") + exit(1) + } + return data } let outputDirectory = CommandLine.arguments[1] -for size in [16, 32, 64, 128, 256, 512, 1024] { - let image = drawIcon(size: CGFloat(size)) - guard let tiff = image.tiffRepresentation, - let bitmap = NSBitmapImageRep(data: tiff), - let png = bitmap.representation(using: .png, properties: [:]) else { - print("could not render \(size)") - exit(1) + +if CommandLine.arguments.contains("--social") { + try socialPreview().write(to: URL(fileURLWithPath: "\(outputDirectory)/social-preview.png")) + print("wrote the social preview to \(outputDirectory)") + exit(0) +} + +if CommandLine.arguments.contains("--marketing") { + // Sizes people actually ask for: favicons, a README header, an avatar, and one big enough to + // hand to a designer or to a store listing. + for size in [16, 32, 48, 64, 128, 180, 256, 512, 1024] { + try png(size: size).write(to: URL(fileURLWithPath: "\(outputDirectory)/icon-\(size).png")) } - let scale = size >= 32 && size <= 512 ? "\(size / 2)x\(size / 2)@2x" : "\(size)x\(size)" - try png.write(to: URL(fileURLWithPath: "\(outputDirectory)/icon_\(scale).png")) - if size <= 512 { - try png.write(to: URL(fileURLWithPath: "\(outputDirectory)/icon_\(size)x\(size).png")) + for size in [400, 512, 1024] { + try png(size: size, edgeToEdge: true) + .write(to: URL(fileURLWithPath: "\(outputDirectory)/icon-square-\(size).png")) } + print("wrote the marketing icons to \(outputDirectory)") + exit(0) +} + +// The ten slots `iconutil` reads, by the only names it accepts. A file named anything else, such as +// `icon_64x64.png` or `icon_1024x1024.png`, is ignored without a word, which is how the icon came to +// stop at 512 and look soft wherever macOS asks for a bigger one. +let slots: [(pixels: Int, name: String)] = [ + (16, "icon_16x16"), + (32, "icon_16x16@2x"), + (32, "icon_32x32"), + (64, "icon_32x32@2x"), + (128, "icon_128x128"), + (256, "icon_128x128@2x"), + (256, "icon_256x256"), + (512, "icon_256x256@2x"), + (512, "icon_512x512"), + (1024, "icon_512x512@2x"), +] +var rendered: [Int: Data] = [:] +for slot in slots { + let data = rendered[slot.pixels] ?? png(size: slot.pixels) + rendered[slot.pixels] = data + try data.write(to: URL(fileURLWithPath: "\(outputDirectory)/\(slot.name).png")) } print("wrote icon set to \(outputDirectory)")