mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ios): show dark app icon in Dark appearance (#113039)
* fix(ios): use dark app icon appearance * fix(ios): cover debug app icon appearances
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 450 KiB |
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 379 KiB |
File diff suppressed because one or more lines are too long
@@ -0,0 +1,182 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
func fail(_ message: String) -> Never {
|
||||
FileHandle.standardError.write(Data("app-icon-debug-dark: \(message)\n".utf8))
|
||||
exit(1)
|
||||
}
|
||||
|
||||
guard CommandLine.arguments.count == 3 else {
|
||||
fail("usage: app-icon-debug-dark.swift <source.png> <output.png>")
|
||||
}
|
||||
|
||||
let sourceURL = URL(fileURLWithPath: CommandLine.arguments[1])
|
||||
let outputURL = URL(fileURLWithPath: CommandLine.arguments[2])
|
||||
|
||||
guard
|
||||
let imageSource = CGImageSourceCreateWithURL(sourceURL as CFURL, nil),
|
||||
let sourceImage = CGImageSourceCreateImageAtIndex(imageSource, 0, nil)
|
||||
else {
|
||||
fail("cannot read \(sourceURL.path)")
|
||||
}
|
||||
|
||||
let width = sourceImage.width
|
||||
let height = sourceImage.height
|
||||
guard width == 1024, height == 1024 else {
|
||||
fail("source must be 1024x1024, got \(width)x\(height)")
|
||||
}
|
||||
|
||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)!
|
||||
let bitmapInfo =
|
||||
CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue
|
||||
var pixels = [UInt8](repeating: 0, count: width * height * 4)
|
||||
guard
|
||||
let context = CGContext(
|
||||
data: &pixels,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: width * 4,
|
||||
space: colorSpace,
|
||||
bitmapInfo: bitmapInfo)
|
||||
else {
|
||||
fail("cannot create an RGBA rendering context")
|
||||
}
|
||||
context.draw(sourceImage, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||
|
||||
func isInsetBackground(pixel: Int) -> Bool {
|
||||
let red = Int(pixels[pixel])
|
||||
let green = Int(pixels[pixel + 1])
|
||||
let blue = Int(pixels[pixel + 2])
|
||||
return pixels[pixel + 3] == 255
|
||||
&& min(red, green, blue) >= 224
|
||||
&& max(red, green, blue) - min(red, green, blue) <= 4
|
||||
}
|
||||
|
||||
// The Debug master is raster-only. Flood-filling from the inset panel keeps the
|
||||
// disconnected white bug glyph and the full-bleed construction frame intact.
|
||||
var visited = [Bool](repeating: false, count: width * height)
|
||||
var cleared = [Bool](repeating: false, count: width * height)
|
||||
var queue = [128 * width + 128]
|
||||
visited[queue[0]] = true
|
||||
var cursor = 0
|
||||
var clearedCount = 0
|
||||
|
||||
while cursor < queue.count {
|
||||
let index = queue[cursor]
|
||||
cursor += 1
|
||||
let pixel = index * 4
|
||||
guard isInsetBackground(pixel: pixel) else { continue }
|
||||
|
||||
cleared[index] = true
|
||||
clearedCount += 1
|
||||
pixels[pixel] = 0
|
||||
pixels[pixel + 1] = 0
|
||||
pixels[pixel + 2] = 0
|
||||
pixels[pixel + 3] = 0
|
||||
|
||||
let x = index % width
|
||||
let y = index / width
|
||||
if x > 0 {
|
||||
let neighbor = index - 1
|
||||
if !visited[neighbor] {
|
||||
visited[neighbor] = true
|
||||
queue.append(neighbor)
|
||||
}
|
||||
}
|
||||
if x + 1 < width {
|
||||
let neighbor = index + 1
|
||||
if !visited[neighbor] {
|
||||
visited[neighbor] = true
|
||||
queue.append(neighbor)
|
||||
}
|
||||
}
|
||||
if y > 0 {
|
||||
let neighbor = index - width
|
||||
if !visited[neighbor] {
|
||||
visited[neighbor] = true
|
||||
queue.append(neighbor)
|
||||
}
|
||||
}
|
||||
if y + 1 < height {
|
||||
let neighbor = index + width
|
||||
if !visited[neighbor] {
|
||||
visited[neighbor] = true
|
||||
queue.append(neighbor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard clearedCount > 300_000 else {
|
||||
fail("expected a connected light inset panel, cleared only \(clearedCount) pixels")
|
||||
}
|
||||
|
||||
// The source artwork was antialiased against white. Remove that matte from the
|
||||
// two-pixel perimeter so the transparent Dark panel cannot produce a white halo.
|
||||
var unmattedCount = 0
|
||||
for index in 0..<(width * height) where !cleared[index] {
|
||||
let x = index % width
|
||||
let y = index / width
|
||||
var touchesBackground = false
|
||||
|
||||
for deltaY in -2...2 where !touchesBackground {
|
||||
for deltaX in -2...2 {
|
||||
let neighborX = x + deltaX
|
||||
let neighborY = y + deltaY
|
||||
if neighborX >= 0,
|
||||
neighborX < width,
|
||||
neighborY >= 0,
|
||||
neighborY < height,
|
||||
cleared[neighborY * width + neighborX]
|
||||
{
|
||||
touchesBackground = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard touchesBackground else { continue }
|
||||
let pixel = index * 4
|
||||
let matte = min(pixels[pixel], pixels[pixel + 1], pixels[pixel + 2])
|
||||
guard matte >= 96 else { continue }
|
||||
|
||||
pixels[pixel] -= matte
|
||||
pixels[pixel + 1] -= matte
|
||||
pixels[pixel + 2] -= matte
|
||||
pixels[pixel + 3] = 255 - matte
|
||||
unmattedCount += 1
|
||||
}
|
||||
|
||||
guard unmattedCount > 1_000 else {
|
||||
fail("expected white-matted inset edges, unmatted only \(unmattedCount) pixels")
|
||||
}
|
||||
|
||||
guard
|
||||
let provider = CGDataProvider(data: Data(pixels) as CFData),
|
||||
let outputImage = CGImage(
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bitsPerPixel: 32,
|
||||
bytesPerRow: width * 4,
|
||||
space: colorSpace,
|
||||
bitmapInfo: CGBitmapInfo(rawValue: bitmapInfo),
|
||||
provider: provider,
|
||||
decode: nil,
|
||||
shouldInterpolate: false,
|
||||
intent: .defaultIntent),
|
||||
let destination = CGImageDestinationCreateWithURL(
|
||||
outputURL as CFURL,
|
||||
UTType.png.identifier as CFString,
|
||||
1,
|
||||
nil)
|
||||
else {
|
||||
fail("cannot create \(outputURL.path)")
|
||||
}
|
||||
|
||||
CGImageDestinationAddImage(destination, outputImage, nil)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
fail("cannot write \(outputURL.path)")
|
||||
}
|
||||
Executable
+187
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "$0")/../../.." && pwd)"
|
||||
release_icon_set="$repo_root/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset"
|
||||
debug_icon_set="$repo_root/apps/ios/Sources/Assets.xcassets/AppIconDebug.appiconset"
|
||||
source_svg="$repo_root/ui/public/favicon.svg"
|
||||
debug_renderer="$repo_root/apps/ios/scripts/app-icon-debug-dark.swift"
|
||||
|
||||
require_command() {
|
||||
local command_name="$1"
|
||||
local install_hint="$2"
|
||||
if ! command -v "$command_name" >/dev/null 2>&1; then
|
||||
echo "app-icon-variants: missing $command_name; $install_hint" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
preflight() {
|
||||
require_command node "install the repository's required Node.js version"
|
||||
require_command xcrun "install Xcode command-line tools"
|
||||
if [[ ! -x /usr/bin/sips ]]; then
|
||||
echo "app-icon-variants: /usr/bin/sips is required; run this generator on macOS" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! xcrun --find swift >/dev/null 2>&1; then
|
||||
echo "app-icon-variants: Swift is required; select a complete Xcode toolchain" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
render_release_dark_icon() {
|
||||
local output="$1"
|
||||
/usr/bin/sips -z 1024 1024 -s format png "$source_svg" --out "$output" >/dev/null
|
||||
}
|
||||
|
||||
render_debug_dark_icon() {
|
||||
local output="$1"
|
||||
xcrun swift "$debug_renderer" "$debug_icon_set/1024.png" "$output"
|
||||
}
|
||||
|
||||
check_manifests() {
|
||||
node - \
|
||||
"$release_icon_set/Contents.json" "$release_icon_set" \
|
||||
"$debug_icon_set/Contents.json" "$debug_icon_set" <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const inputs = process.argv.slice(2);
|
||||
const luminosity = (image) =>
|
||||
image.appearances?.find((entry) => entry.appearance === "luminosity")?.value;
|
||||
|
||||
for (let index = 0; index < inputs.length; index += 2) {
|
||||
const manifestPath = inputs[index];
|
||||
const iconSetPath = inputs[index + 1];
|
||||
const catalogName = path.basename(iconSetPath, ".appiconset");
|
||||
const { images } = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
const dark = images.filter((image) => luminosity(image) === "dark");
|
||||
const tinted = images.filter((image) => luminosity(image) === "tinted");
|
||||
const marketing = images.filter(
|
||||
(image) =>
|
||||
image.idiom === "ios-marketing" &&
|
||||
image.size === "1024x1024" &&
|
||||
image.scale === "1x",
|
||||
);
|
||||
|
||||
if (
|
||||
marketing.length !== 1 ||
|
||||
marketing[0].filename !== "1024.png" ||
|
||||
dark.length !== 1 ||
|
||||
dark[0].filename !== "1024-dark.png" ||
|
||||
dark[0].idiom !== "universal" ||
|
||||
dark[0].platform !== "ios" ||
|
||||
dark[0].size !== "1024x1024" ||
|
||||
tinted.length !== 1 ||
|
||||
Object.hasOwn(tinted[0], "filename") ||
|
||||
tinted[0].idiom !== "universal" ||
|
||||
tinted[0].platform !== "ios" ||
|
||||
tinted[0].size !== "1024x1024"
|
||||
) {
|
||||
throw new Error(
|
||||
`${catalogName} must declare its existing Default image, one custom Dark image, and one automatic Tinted slot`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const filename of new Set(images.flatMap((image) => image.filename ?? []))) {
|
||||
if (!fs.existsSync(path.join(iconSetPath, filename))) {
|
||||
throw new Error(`${catalogName} references missing file: ${filename}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
NODE
|
||||
}
|
||||
|
||||
check_pngs() {
|
||||
node - \
|
||||
"$release_icon_set/1024.png" opaque any \
|
||||
"$release_icon_set/1024-dark.png" alpha srgb \
|
||||
"$debug_icon_set/1024.png" opaque srgb \
|
||||
"$debug_icon_set/1024-dark.png" alpha srgb <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
|
||||
const inputs = process.argv.slice(2);
|
||||
const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
|
||||
for (let index = 0; index < inputs.length; index += 3) {
|
||||
const imagePath = inputs[index];
|
||||
const alphaExpectation = inputs[index + 1];
|
||||
const profileExpectation = inputs[index + 2];
|
||||
const data = fs.readFileSync(imagePath);
|
||||
if (!data.subarray(0, 8).equals(pngSignature)) {
|
||||
throw new Error(`${imagePath} is not a PNG`);
|
||||
}
|
||||
|
||||
let offset = 8;
|
||||
let header;
|
||||
let hasSRGBChunk = false;
|
||||
let iccProfileName;
|
||||
while (offset + 12 <= data.length) {
|
||||
const length = data.readUInt32BE(offset);
|
||||
const type = data.toString("ascii", offset + 4, offset + 8);
|
||||
const body = data.subarray(offset + 8, offset + 8 + length);
|
||||
if (type === "IHDR") {
|
||||
header = {
|
||||
width: body.readUInt32BE(0),
|
||||
height: body.readUInt32BE(4),
|
||||
bitDepth: body[8],
|
||||
colorType: body[9],
|
||||
};
|
||||
} else if (type === "sRGB") {
|
||||
hasSRGBChunk = true;
|
||||
} else if (type === "iCCP") {
|
||||
iccProfileName = body.subarray(0, body.indexOf(0)).toString("latin1");
|
||||
}
|
||||
offset += 12 + length;
|
||||
if (type === "IEND") break;
|
||||
}
|
||||
|
||||
if (
|
||||
!header ||
|
||||
header.width !== 1024 ||
|
||||
header.height !== 1024 ||
|
||||
header.bitDepth !== 8
|
||||
) {
|
||||
throw new Error(`${imagePath} must be an 8-bit 1024x1024 PNG`);
|
||||
}
|
||||
|
||||
const hasAlphaChannel = header.colorType === 4 || header.colorType === 6;
|
||||
if ((alphaExpectation === "alpha") !== hasAlphaChannel) {
|
||||
throw new Error(`${imagePath} has an unexpected PNG alpha-channel shape`);
|
||||
}
|
||||
|
||||
if (
|
||||
profileExpectation === "srgb" &&
|
||||
!hasSRGBChunk &&
|
||||
!iccProfileName?.toLowerCase().startsWith("srgb")
|
||||
) {
|
||||
throw new Error(`${imagePath} must declare an sRGB color profile`);
|
||||
}
|
||||
}
|
||||
NODE
|
||||
}
|
||||
|
||||
case "${1:-check}" in
|
||||
generate)
|
||||
preflight
|
||||
render_release_dark_icon "$release_icon_set/1024-dark.png"
|
||||
render_debug_dark_icon "$debug_icon_set/1024-dark.png"
|
||||
;;
|
||||
check)
|
||||
preflight
|
||||
check_manifests
|
||||
check_pngs
|
||||
|
||||
temp_dir="$(mktemp -d /tmp/openclaw-app-icon-variants.XXXXXX)"
|
||||
trap 'rm -rf "$temp_dir"' EXIT
|
||||
render_release_dark_icon "$temp_dir/1024-dark.png"
|
||||
render_debug_dark_icon "$temp_dir/1024-debug-dark.png"
|
||||
cmp "$release_icon_set/1024-dark.png" "$temp_dir/1024-dark.png"
|
||||
cmp "$debug_icon_set/1024-dark.png" "$temp_dir/1024-debug-dark.png"
|
||||
echo "AppIcon and AppIconDebug Default, Dark, and automatic Tinted variants are valid."
|
||||
;;
|
||||
*)
|
||||
echo "usage: $0 [generate|check]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user