Module: Everywhere::Icon

Defined in:
lib/everywhere/icon.rb

Overview

Turns one source PNG into platform-native app icons — no Tauri, no sips, no iconutil, no ImageMagick. Pure Ruby, so it runs the same locally and on the cloud build runners.

The important part is macОS shaping. macOS renders an .icns literally: it does not round the corners or add margins the way iOS does. A full-bleed square PNG therefore shows up as a hard square, out of place next to every native app. So we bake the platform's look into the artwork ourselves — inset the icon to the Big Sur grid (~824px inside a 1024px canvas) and clip it to a superellipse ("squircle") with transparent corners.

Windows (.ico) and Linux (hicolor PNGs) don't get the squircle — those desktops present square icons — so they're generated straight from the full-bleed source.

Constant Summary collapse

CANVAS =

Big Sur icon-grid geometry, all as fractions of the 1024px canvas so it scales to any master size.

1024
INSET =

icon body is 824/1024 of the canvas

0.8047
CORNER_RATIO =

corner radius as a fraction of the body's side

0.2237
EXPONENT =

superellipse power — the iOS/macOS "squircle" corner

5.0
ICNS_TYPES =

.icns type codes -> pixel size. Mirrors what iconutil emits from a standard .iconset (retina variants included), so the result is indistinguishable from an Apple-toolchain icns.

[
  ["icp4", 16], ["icp5", 32], ["ic07", 128], ["ic08", 256], ["ic09", 512],
  ["ic11", 32], ["ic12", 64], ["ic13", 256], ["ic14", 512], ["ic10", 1024]
].freeze
ICO_SIZES =
[16, 32, 48, 64, 128, 256].freeze
LINUX_SIZES =
[16, 32, 48, 64, 128, 256, 512].freeze
ANDROID_ADAPTIVE_SIZES =

The adaptive icon's foreground layer, one entry per density bucket (mdpi is 1x, the rest step 1.5/2/3/4x) of the 108dp layer. No legacy mipmap-/ic_launcher.png companions: the shell's minSdk is 28, so every device that can install the app resolves @mipmap/ic_launcher to the anydpi-v26 adaptive icon and could never load them.

{
  "mdpi" => 108, "hdpi" => 162, "xhdpi" => 216, "xxhdpi" => 324, "xxxhdpi" => 432
}.freeze
ADAPTIVE_ART =

Adaptive-icon safe-zone geometry, as a fraction of the 108dp layer.

The launcher masks both layers to a shape it chooses and can parallax them, so only the centre 72dp is ever guaranteed visible — the outer 18dp on each side is headroom. The most aggressive stock mask is the circle inscribed in that 72dp viewport, and Google's keyline pulls the safe zone one step further in, to a 66dp circle. So a logo goes inside that circle, bounding box and all: side = 66/sqrt(2) = 46.7dp, i.e. 0.432 of the layer. Scaling a logo to the 72dp viewport instead is the usual mistake — it looks correct in a square preview and loses every corner on the round-mask launchers most devices ship with.

(66.0 / Math.sqrt(2)) / 108.0
CONTENT_ALPHA =

Alpha at or below this is background, not artwork — it survives PNG export slop and antialiased edges without counting them as content.

8
FULL_BLEED_COVERAGE =

Opaque fraction of the source above which it is treated as full-bleed art rather than a logo. config.icon is one shared icon.png feeding macOS, iOS and Android, and the iOS path wants it opaque corner to corner, so in practice it usually is. Insetting that into the safe zone would render the source's own baked background as a shrunken tile floating inside the launcher's background colour — two nested backgrounds and a tiny logo.

0.95 sits in an empty gap. Shaped-but-full-bleed sources stay above it: a Big Sur squircle keeps ~99% of its bounding square, a 22%-rounded rect ~97%. Anything with a deliberate margin falls far below — even a mere 5% border leaves 81%. Nothing real lands near the line.

0.95
ANDROID_ADAPTIVE_ICON_XML =

Both launcher aliases resolve to the same layers; ic_launcher_round exists only because the manifest's android:roundIcon asks for it by name. The layers are @mipmap, not the template placeholder's @drawable, because what gets stamped is a raster per density rather than one vector.

<<~XML
  <?xml version="1.0" encoding="utf-8"?>
  <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
      <background android:drawable="@color/ic_launcher_background" />
      <foreground android:drawable="@mipmap/ic_launcher_foreground" />
  </adaptive-icon>
XML
ANDROID_THEMED_ADAPTIVE_ICON_XML =

Android 13+ themed ("Material You") icons tint the monochrome layer's alpha and throw its colour away, so the layer is only worth emitting when the alpha is already a silhouette — i.e. the inset branch, where the source arrived as a logo on transparency. Full-bleed art would tint as a solid filled square, which is worse than the launcher's own fallback, so that branch ships the icon without a monochrome layer and lets the fallback happen.

<<~XML
  <?xml version="1.0" encoding="utf-8"?>
  <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
      <background android:drawable="@color/ic_launcher_background" />
      <foreground android:drawable="@mipmap/ic_launcher_foreground" />
      <monochrome android:drawable="@mipmap/ic_launcher_foreground" />
  </adaptive-icon>
XML
ANDROID_BACKGROUND_XML =

The background layer is a colour, not a bitmap: it has to survive being scaled and shifted by the launcher's parallax, which a PNG can't do without banding or seams at the edges.

<<~XML
  <?xml version="1.0" encoding="utf-8"?>
  <resources>
      <color name="ic_launcher_background">%s</color>
  </resources>
XML
IOS_APPICON_CONTENTS =

Modern (Xcode 14+) single-size asset catalog entry: one 1024px universal image, iOS derives every size itself.

<<~JSON
  {
    "images" : [
      {
        "filename" : "AppIcon.png",
        "idiom" : "universal",
        "platform" : "ios",
        "size" : "1024x1024"
      }
    ],
    "info" : {
      "author" : "xcode",
      "version" : 1
    }
  }
JSON

Class Method Summary collapse

Class Method Details

.android_art(raster) ⇒ Object

How this source wants to be scaled onto a launcher layer. Returns [art, full_bleed] — either the raster untouched, to be scaled edge to edge because it is art designed to be cropped by the mask, or the source cropped to its opaque bounding box, to be inset into the safe zone.

Cropping matters for the inset branch: scaling the whole canvas would let the source's own transparent padding eat into the 66dp circle and land the logo smaller than the safe zone actually allows. A source with no content at all is nothing to measure, so it takes the full-bleed path.



298
299
300
301
302
303
# File 'lib/everywhere/icon.rb', line 298

def android_art(raster)
  bounds, coverage = content_bounds(raster)
  return [raster, true] if bounds.nil? || coverage >= FULL_BLEED_COVERAGE

  [crop(raster, *bounds), false]
end

.centred(raster, canvas, box) ⇒ Object

raster scaled to fit a box-sized square (aspect preserved, never stretched) and centred on a transparent canvasxcanvas raster.



356
357
358
359
360
361
362
363
364
# File 'lib/everywhere/icon.rb', line 356

def centred(raster, canvas, box)
  fit = [box.to_f / raster.width, box.to_f / raster.height].min
  sw = (raster.width * fit).round.clamp(1, box)
  sh = (raster.height * fit).round.clamp(1, box)

  out = Raster.transparent(canvas, canvas)
  out.paste!(raster.resample(sw, sh), (canvas - sw) / 2, (canvas - sh) / 2)
  out
end

.content_bounds(raster) ⇒ Object

[[x0, y0, x1, y1], coverage] for the pixels above CONTENT_ALPHA — the half-open bounding box of the artwork and the fraction of the canvas it actually paints. [nil, 0.0] when the raster is entirely transparent.



308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
# File 'lib/everywhere/icon.rb', line 308

def content_bounds(raster)
  x0 = raster.width
  y0 = raster.height
  x1 = 0
  y1 = 0
  opaque = 0

  raster.height.times do |y|
    base = y * raster.width
    raster.width.times do |x|
      next if raster.pixels.getbyte(((base + x) * 4) + 3) <= CONTENT_ALPHA

      opaque += 1
      x0 = x if x < x0
      x1 = x + 1 if x >= x1
      y0 = y if y < y0
      y1 = y + 1 if y >= y1
    end
  end

  return [nil, 0.0] if opaque.zero?

  [[x0, y0, x1, y1], opaque.to_f / (raster.width * raster.height)]
end

.crop(raster, x0, y0, x1, y1) ⇒ Object

The [x0, y0, x1, y1) region of raster as a new raster.



334
335
336
337
338
339
340
# File 'lib/everywhere/icon.rb', line 334

def crop(raster, x0, y0, x1, y1)
  w = x1 - x0
  h = y1 - y0
  out = String.new(capacity: w * h * 4)
  h.times { |y| out << raster.pixels.byteslice(((((y0 + y) * raster.width) + x0) * 4), w * 4) }
  Raster.new(w, h, out)
end

.filling(raster, canvas) ⇒ Object

raster scaled to cover a canvasxcanvas square and centre-cropped to it — no transparent border anywhere. Full-bleed art is meant to be cropped, so a non-square source loses its overhang rather than exposing bands of background the launcher's mask would read as a mistake.



346
347
348
349
350
351
352
# File 'lib/everywhere/icon.rb', line 346

def filling(raster, canvas)
  fill = [canvas.to_f / raster.width, canvas.to_f / raster.height].max
  sw = [(raster.width * fill).ceil, canvas].max
  sh = [(raster.height * fill).ceil, canvas].max
  crop(raster.resample(sw, sh), (sw - canvas) / 2, (sh - canvas) / 2,
       ((sw - canvas) / 2) + canvas, ((sh - canvas) / 2) + canvas)
end

.hex_rgb(hex) ⇒ Object

"#RRGGBB" (or "RRGGBBAA" — alpha ignored) -> [r, g, b] bytes, nil if unparseable. For resolving appearance colors into flatten backdrops.



284
285
286
287
# File 'lib/everywhere/icon.rb', line 284

def hex_rgb(hex)
  m = hex.to_s.strip.match(/\A#?(\h{2})(\h{2})(\h{2})/) or return nil
  m.captures.map { |c| c.to_i(16) }
end

.macos_master(source, dest, canvas: CANVAS) ⇒ Object

Write a macOS-shaped 1024px RGBA master PNG (padding + squircle) from a source PNG. Returns true, or false if the source PNG can't be read.



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/everywhere/icon.rb', line 140

def macos_master(source, dest, canvas: CANVAS)
  raster = Raster.decode_png(source) or return false

  body = (canvas * INSET).round
  offset = (canvas - body) / 2

  # Fit the artwork inside the body square preserving aspect ratio (square
  # sources fill it exactly; non-square ones are centred, not stretched).
  fit = [body.to_f / raster.width, body.to_f / raster.height].min
  sw = (raster.width * fit).round.clamp(1, body)
  sh = (raster.height * fit).round.clamp(1, body)
  scaled = raster.resample(sw, sh)

  out = Raster.transparent(canvas, canvas)
  out.paste!(scaled, offset + (body - sw) / 2, offset + (body - sh) / 2)

  centre = canvas / 2.0
  half = body / 2.0
  radius = [body * CORNER_RATIO, half].min
  straight = half - radius # half-extent of the straight edges
  out.mask_region!(offset, offset, offset + body, offset + body) do |x, y|
    squircle_coverage(x, y, centre, half, straight, radius, EXPONENT)
  end

  out.write_png(dest)
  true
end

.sized_png(master, size) ⇒ Object

PNG bytes of master at sizexsize (no-op resample when already sized).



367
368
369
# File 'lib/everywhere/icon.rb', line 367

def sized_png(master, size)
  (size == master.width && size == master.height ? master : master.resample(size, size)).encode_png
end

.squircle_coverage(x, y, centre, half, straight, radius, exponent) ⇒ Object

Coverage (0.0..1.0) of pixel (x, y) by the squircle: a rounded rectangle with continuous (superelliptical) corners. Interior pixels return 1.0, exterior 0.0, and boundary pixels are antialiased by 4x4 supersampling.



374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
# File 'lib/everywhere/icon.rb', line 374

def squircle_coverage(x, y, centre, half, straight, radius, exponent)
  c0 = squircle_inside?(x,     y,     centre, half, straight, radius, exponent)
  c1 = squircle_inside?(x + 1, y,     centre, half, straight, radius, exponent)
  c2 = squircle_inside?(x,     y + 1, centre, half, straight, radius, exponent)
  c3 = squircle_inside?(x + 1, y + 1, centre, half, straight, radius, exponent)
  return 1.0 if c0 && c1 && c2 && c3
  return 0.0 unless c0 || c1 || c2 || c3

  hit = 0
  4.times do |i|
    4.times do |j|
      hit += 1 if squircle_inside?(x + (i + 0.5) / 4.0, y + (j + 0.5) / 4.0,
                                   centre, half, straight, radius, exponent)
    end
  end
  hit / 16.0
end

.squircle_inside?(sx, sy, centre, half, straight, radius, exponent) ⇒ Boolean

Is the continuous point (sx, sy) inside the squircle? Straight edges up to straight from centre, superelliptical corners of radius beyond that.

Returns:

  • (Boolean)


394
395
396
397
398
399
400
401
402
403
# File 'lib/everywhere/icon.rb', line 394

def squircle_inside?(sx, sy, centre, half, straight, radius, exponent)
  u = (sx - centre).abs
  v = (sy - centre).abs
  return false if u > half || v > half
  return true if u <= straight || v <= straight

  du = (u - straight) / radius
  dv = (v - straight) / radius
  (du**exponent) + (dv**exponent) <= 1.0
end

.write_android_mipmaps(source, res_dir, background: nil) ⇒ Object

Write the Android launcher icon under res_dir: an adaptive icon's foreground layer per density, the solid-colour background resource, and the anydpi-v26 pair that binds them (ic_launcher and the ic_launcher_round alias the manifest names). How large the artwork lands, and whether it can carry a themed-icon layer, both follow from what the source turns out to be — see android_art. background is an [r, g, b] triple as Icon.hex_rgb returns, default white. Returns true, or false if the source PNG can't be read — same contract as write_ios_appiconset, so the builder can warn and ship the placeholder.



201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'lib/everywhere/icon.rb', line 201

def write_android_mipmaps(source, res_dir, background: nil)
  raster = Raster.decode_png(source) or return false

  rgb = Array(background || [255, 255, 255])[0, 3]
  art, full_bleed = android_art(raster)

  ANDROID_ADAPTIVE_SIZES.each do |density, layer|
    dir = File.join(res_dir, "mipmap-#{density}")
    FileUtils.mkdir_p(dir)

    # The foreground keeps its alpha — the launcher composites it over the
    # background layer, and its mask needs the surround empty to cut into.
    foreground = if full_bleed
                   filling(art, layer)
                 else
                   centred(art, layer, (layer * ADAPTIVE_ART).round)
                 end
    foreground.write_png(File.join(dir, "ic_launcher_foreground.png"))
  end

  xml = full_bleed ? ANDROID_ADAPTIVE_ICON_XML : ANDROID_THEMED_ADAPTIVE_ICON_XML
  anydpi = File.join(res_dir, "mipmap-anydpi-v26")
  FileUtils.mkdir_p(anydpi)
  File.write(File.join(anydpi, "ic_launcher.xml"), xml)
  File.write(File.join(anydpi, "ic_launcher_round.xml"), xml)

  values = File.join(res_dir, "values")
  FileUtils.mkdir_p(values)
  File.write(File.join(values, "ic_launcher_background.xml"),
             format(ANDROID_BACKGROUND_XML, format("#%02X%02X%02X", *rgb)))
  true
end

.write_icns(master_png, dest) ⇒ Object

Build a macOS .icns from an already-shaped master PNG (ideally 1024px). Returns true on success.



236
237
238
239
240
241
242
243
244
245
246
# File 'lib/everywhere/icon.rb', line 236

def write_icns(master_png, dest)
  master = Raster.decode_png(master_png) or return false

  body = ICNS_TYPES.map do |type, size|
    png = sized_png(master, size)
    type.b + [png.bytesize + 8].pack("N") + png
  end.join

  File.binwrite(dest, "icns".b + [body.bytesize + 8].pack("N") + body)
  true
end

.write_ico(source, dest, sizes: ICO_SIZES) ⇒ Object

Build a Windows .ico (PNG-compressed entries, Vista+) from a full-bleed source PNG. Returns true on success.



250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
# File 'lib/everywhere/icon.rb', line 250

def write_ico(source, dest, sizes: ICO_SIZES)
  master = Raster.decode_png(source) or return false

  images = sizes.map { |s| [s, sized_png(master, s)] }
  offset = 6 + images.size * 16
  entries = +"".b
  blob = +"".b
  images.each do |size, png|
    dim = size >= 256 ? 0 : size # 0 means 256 in the ICONDIRENTRY
    entries << [dim, dim, 0, 0, 1, 32, png.bytesize, offset].pack("CCCCvvVV")
    blob << png
    offset += png.bytesize
  end

  header = [0, 1, images.size].pack("vvv") # reserved, type=icon, count
  File.binwrite(dest, header + entries + blob)
  true
end

.write_ios_appiconset(source, dest_dir, background: nil) ⇒ Object

Write an iOS AppIcon.appiconset (Contents.json + one 1024px PNG) from a full-bleed source PNG. Unlike macOS, iOS masks the icon itself, so the artwork ships as a plain square — no inset, no squircle. The App Store rejects alpha channels, so the source is flattened onto background ([r, g, b] bytes, default white). Returns true, false if unreadable.



173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
# File 'lib/everywhere/icon.rb', line 173

def write_ios_appiconset(source, dest_dir, background: nil)
  raster = Raster.decode_png(source) or return false

  # Fit the artwork inside the canvas preserving aspect ratio (square
  # sources fill it exactly), then flatten onto the opaque backdrop.
  fit = [CANVAS.to_f / raster.width, CANVAS.to_f / raster.height].min
  sw = (raster.width * fit).round.clamp(1, CANVAS)
  sh = (raster.height * fit).round.clamp(1, CANVAS)

  canvas = Raster.transparent(CANVAS, CANVAS)
  canvas.paste!(raster.resample(sw, sh), (CANVAS - sw) / 2, (CANVAS - sh) / 2)
  out = canvas.flatten_onto(Array(background || [255, 255, 255])[0, 3])

  FileUtils.mkdir_p(dest_dir)
  out.write_png(File.join(dest_dir, "AppIcon.png"))
  File.write(File.join(dest_dir, "Contents.json"), IOS_APPICON_CONTENTS)
  true
end

.write_png_set(source, dest_dir, sizes: LINUX_SIZES) ⇒ Object

Write a set of square PNGs (Linux/desktop use) named "x.png" into dest_dir from a full-bleed source. Returns the written paths.



271
272
273
274
275
276
277
278
279
280
# File 'lib/everywhere/icon.rb', line 271

def write_png_set(source, dest_dir, sizes: LINUX_SIZES)
  master = Raster.decode_png(source) or return []

  FileUtils.mkdir_p(dest_dir)
  sizes.map do |size|
    path = File.join(dest_dir, "#{size}x#{size}.png")
    File.binwrite(path, sized_png(master, size))
    path
  end
end