fix (ios): Add dark and tinted app icon variants

This commit is contained in:
Darkatse 2026-04-25 12:24:05 -04:00
parent aa52400feb
commit 303153cbf1
27 changed files with 346 additions and 108 deletions

View file

@ -178,3 +178,50 @@ iOS 上“文件选择 / 文件导出”必须交给系统级能力完成:
iOS 外测/内测分发裁剪与能力分级已落地为 iOS-only 的 `ios_policy` 运行时系统(可被导入 `tauritavern-settings.json` 覆盖 profile/能力边界,且 iOS 上 fail-fast、桌面端忽略)。
- 当前实现快照:`docs/CurrentState/iOSPolicy.md`
## 6. iOS 18+ App Icon 外观变体
### 6.1 现象
iOS 深色图标模式下,App 放入文件夹后可能出现“文件夹外仍是普通图标,打开文件夹后变成深色图标”的不一致表现。
### 6.2 根因
iOS 18+ 支持 Home Screen 图标的 `Any` / `Dark` / `Tinted` 外观。若 AppIcon 只提供传统多尺寸 `Any` 图标,系统会自动生成深色/着色效果;文件夹缩略图和展开文件夹可能走不同缓存或渲染路径,从而出现外观不一致。
### 6.3 当前方案
`AppIcon.appiconset` 改为 Xcode single-size 1024px 源图,并显式提供:
- `AppIcon-Light.png`:基础 `Any` 图标,不透明背景。
- `AppIcon-Dark.png`:深色图标,透明背景,交给系统深色底承载。
- `AppIcon-Tinted.png`:着色图标,透明背景,灰度前景。
维护入口:
- 生成脚本:`scripts/generate-ios-app-icon-variants.swift`
- 构建期校验/展平:`scripts/ios-opaque-app-icons.swift`
- 资产目录:`src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset`
重新生成:
```sh
xcrun --sdk macosx swift scripts/generate-ios-app-icon-variants.swift \
src-tauri/icons/icon.png \
src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset
```
回归验证:
```sh
xcrun actool --compile /tmp/tt-appicon \
--platform iphonesimulator \
--minimum-deployment-target 16.0 \
--app-icon AppIcon \
--output-partial-info-plist /tmp/tt-appicon/partial.plist \
src-tauri/gen/apple/Assets.xcassets
xcrun assetutil --info /tmp/tt-appicon/Assets.car
```
输出应包含 `UIAppearanceDark` 与 `ISAppearanceTintable`。若未来重新运行 `tauri icon`,必须重新生成并保留这三个 appearance 变体。

View file

@ -51,10 +51,12 @@ powershell -ExecutionPolicy Bypass -File .\scripts\export-sillytavern-migration.
校验前端宿主层文件规模和依赖边界,避免 Host Kernel 持续膨胀。对应 `pnpm run check:frontend`。
- `tauri-ios-xcode-script.sh`
包装 `tauri ios xcode-script`,补齐 Xcode GUI 构建环境中的 PATH / Node / pnpm,并在构建后处理 iOS 图标。
- `generate-ios-app-icon-variants.swift`
从 `src-tauri/icons/icon.png` 生成 iOS `Any` / `Dark` / `Tinted` 三个 1024px App Icon 源图。
- `ios-policy.mjs`
iOS Dev/Build 包装脚本:为构建过程注入 `TAURITAVERN_IOS_POLICY_PROFILE`,并在 `ios_internal_full` / `ios_external_beta` 构建时自动使用 `--export-method app-store-connect`。
- `ios-opaque-app-icons.swift`
将 iOS App Icon PNG 展平为不透明背景,供 `tauri-ios-xcode-script.sh` 调用。
校验 iOS App Icon appearance 变体,并只将基础 `Any` 图标展平为不透明背景,供 `tauri-ios-xcode-script.sh` 调用。
- `ci/setup-macos-signing.sh`
GitHub Actions / CI 中的 macOS 签名初始化脚本,用于导入证书、创建 keychain 与写入 Apple API Key 路径。
- `guardrails/frontend-lines-baseline.json`

View file

@ -0,0 +1,189 @@
#!/usr/bin/env swift
import CoreGraphics
import Foundation
import ImageIO
import UniformTypeIdentifiers
struct HexColor {
let red: CGFloat
let green: CGFloat
let blue: CGFloat
init(_ hex: String) {
let sanitized = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
guard sanitized.count == 6, let value = UInt64(sanitized, radix: 16) else {
fatalError("Invalid hex color: \(hex)")
}
red = CGFloat((value >> 16) & 0xFF) / 255.0
green = CGFloat((value >> 8) & 0xFF) / 255.0
blue = CGFloat(value & 0xFF) / 255.0
}
}
func loadImage(at url: URL) throws -> CGImage {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
throw NSError(domain: "generate-ios-app-icon-variants", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Failed to load source icon at \(url.path)"
])
}
return image
}
func writePNG(_ image: CGImage, to url: URL) throws {
guard let destination = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil) else {
throw NSError(domain: "generate-ios-app-icon-variants", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Failed to create output file at \(url.path)"
])
}
CGImageDestinationAddImage(destination, image, nil)
guard CGImageDestinationFinalize(destination) else {
throw NSError(domain: "generate-ios-app-icon-variants", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Failed to write PNG at \(url.path)"
])
}
}
func aspectFitRect(sourceWidth: Int, sourceHeight: Int, targetSize: Int) -> CGRect {
let target = CGFloat(targetSize)
let sourceAspect = CGFloat(sourceWidth) / CGFloat(sourceHeight)
let targetAspect: CGFloat = 1
if sourceAspect > targetAspect {
let height = target / sourceAspect
return CGRect(x: 0, y: (target - height) / 2, width: target, height: height)
}
let width = target * sourceAspect
return CGRect(x: (target - width) / 2, y: 0, width: width, height: target)
}
func renderIcon(source: CGImage, size: Int, background: HexColor?) throws -> CGImage {
let bitmapInfo: UInt32
if background == nil {
bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue
} else {
bitmapInfo = CGImageAlphaInfo.noneSkipLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue
}
guard let context = CGContext(
data: nil,
width: size,
height: size,
bitsPerComponent: 8,
bytesPerRow: size * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: bitmapInfo
) else {
throw NSError(domain: "generate-ios-app-icon-variants", code: 4, userInfo: [
NSLocalizedDescriptionKey: "Failed to create drawing context"
])
}
let bounds = CGRect(x: 0, y: 0, width: size, height: size)
if let background {
context.setFillColor(CGColor(red: background.red, green: background.green, blue: background.blue, alpha: 1))
context.fill(bounds)
} else {
context.clear(bounds)
}
context.interpolationQuality = .high
context.draw(source, in: aspectFitRect(sourceWidth: source.width, sourceHeight: source.height, targetSize: size))
guard let rendered = context.makeImage() else {
throw NSError(domain: "generate-ios-app-icon-variants", code: 5, userInfo: [
NSLocalizedDescriptionKey: "Failed to finalize icon image"
])
}
return rendered
}
func renderTintedIcon(source: CGImage, size: Int) throws -> CGImage {
let bytesPerPixel = 4
let bytesPerRow = size * bytesPerPixel
let byteCount = bytesPerRow * size
guard let data = calloc(byteCount, MemoryLayout<UInt8>.size) else {
throw NSError(domain: "generate-ios-app-icon-variants", code: 6, userInfo: [
NSLocalizedDescriptionKey: "Failed to allocate image buffer"
])
}
defer { free(data) }
let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue
guard let context = CGContext(
data: data,
width: size,
height: size,
bitsPerComponent: 8,
bytesPerRow: bytesPerRow,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: bitmapInfo
) else {
throw NSError(domain: "generate-ios-app-icon-variants", code: 7, userInfo: [
NSLocalizedDescriptionKey: "Failed to create tinted drawing context"
])
}
context.clear(CGRect(x: 0, y: 0, width: size, height: size))
context.interpolationQuality = .high
context.draw(source, in: aspectFitRect(sourceWidth: source.width, sourceHeight: source.height, targetSize: size))
let pixels = data.assumingMemoryBound(to: UInt8.self)
for offset in stride(from: 0, to: byteCount, by: bytesPerPixel) {
let alpha = Double(pixels[offset + 3]) / 255.0
guard alpha > 0 else {
continue
}
let red = min(255.0, Double(pixels[offset]) / alpha)
let green = min(255.0, Double(pixels[offset + 1]) / alpha)
let blue = min(255.0, Double(pixels[offset + 2]) / alpha)
let luma = (0.299 * red) + (0.587 * green) + (0.114 * blue)
let premultipliedLuma = UInt8(max(0, min(255, Int((luma * alpha).rounded()))))
pixels[offset] = premultipliedLuma
pixels[offset + 1] = premultipliedLuma
pixels[offset + 2] = premultipliedLuma
}
guard let rendered = context.makeImage() else {
throw NSError(domain: "generate-ios-app-icon-variants", code: 8, userInfo: [
NSLocalizedDescriptionKey: "Failed to finalize tinted icon image"
])
}
return rendered
}
let arguments = CommandLine.arguments.dropFirst()
guard arguments.count >= 2 else {
fputs("usage: generate-ios-app-icon-variants.swift <source_icon> <AppIcon.appiconset> [light_background_hex]\n", stderr)
exit(64)
}
let sourceURL = URL(fileURLWithPath: arguments[arguments.startIndex])
let appIconSetURL = URL(fileURLWithPath: arguments[arguments.index(after: arguments.startIndex)], isDirectory: true)
let lightBackground = HexColor(arguments.dropFirst(2).first ?? "FFFAF2")
let iconSize = 1024
let source = try loadImage(at: sourceURL)
try FileManager.default.createDirectory(at: appIconSetURL, withIntermediateDirectories: true)
try writePNG(
try renderIcon(source: source, size: iconSize, background: lightBackground),
to: appIconSetURL.appendingPathComponent("AppIcon-Light.png")
)
try writePNG(
try renderIcon(source: source, size: iconSize, background: nil),
to: appIconSetURL.appendingPathComponent("AppIcon-Dark.png")
)
try writePNG(
try renderTintedIcon(source: source, size: iconSize),
to: appIconSetURL.appendingPathComponent("AppIcon-Tinted.png")
)

View file

@ -5,6 +5,20 @@ import Foundation
import ImageIO
import UniformTypeIdentifiers
struct AppIconSet: Decodable {
let images: [AppIconImage]
}
struct AppIconImage: Decodable {
let filename: String?
let appearances: [AppIconAppearance]?
}
struct AppIconAppearance: Decodable {
let appearance: String
let value: String
}
struct HexColor {
let red: CGFloat
let green: CGFloat
@ -22,6 +36,62 @@ struct HexColor {
}
}
func loadAppIconSet(at url: URL) throws -> AppIconSet {
let contentsURL = url.appendingPathComponent("Contents.json")
let data = try Data(contentsOf: contentsURL)
return try JSONDecoder().decode(AppIconSet.self, from: data)
}
func luminosityValues(for image: AppIconImage) -> Set<String> {
Set((image.appearances ?? [])
.filter { $0.appearance == "luminosity" }
.map(\.value))
}
func requireIconVariantFiles(
appIconSet: AppIconSet,
appIconSetURL: URL,
fileManager: FileManager
) throws -> Set<String> {
var primaryFilenames = Set<String>()
var darkFilenames = Set<String>()
var tintedFilenames = Set<String>()
for image in appIconSet.images {
guard let filename = image.filename else {
continue
}
let values = luminosityValues(for: image)
if values.isEmpty {
primaryFilenames.insert(filename)
}
if values.contains("dark") {
darkFilenames.insert(filename)
}
if values.contains("tinted") {
tintedFilenames.insert(filename)
}
}
if primaryFilenames.isEmpty || darkFilenames.isEmpty || tintedFilenames.isEmpty {
throw NSError(domain: "ios-opaque-app-icons", code: 6, userInfo: [
NSLocalizedDescriptionKey: "AppIcon.appiconset must define Any, Dark, and Tinted icon variants"
])
}
for filename in primaryFilenames.union(darkFilenames).union(tintedFilenames) {
let fileURL = appIconSetURL.appendingPathComponent(filename)
guard fileManager.fileExists(atPath: fileURL.path) else {
throw NSError(domain: "ios-opaque-app-icons", code: 7, userInfo: [
NSLocalizedDescriptionKey: "AppIcon.appiconset references missing icon file: \(filename)"
])
}
}
return primaryFilenames
}
func flattenPNG(at url: URL, background: HexColor) throws {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
@ -85,7 +155,7 @@ guard let appIconSetArgument = arguments.first else {
exit(64)
}
let background = HexColor(arguments.dropFirst().first ?? "FFF3D6")
let background = HexColor(arguments.dropFirst().first ?? "FFFAF2")
let appIconSetURL = URL(fileURLWithPath: appIconSetArgument, isDirectory: true)
let fileManager = FileManager.default
@ -93,12 +163,20 @@ guard fileManager.fileExists(atPath: appIconSetURL.path) else {
exit(0)
}
let appIconSet = try loadAppIconSet(at: appIconSetURL)
let primaryIconFilenames = try requireIconVariantFiles(
appIconSet: appIconSet,
appIconSetURL: appIconSetURL,
fileManager: fileManager
)
let pngURLs = try fileManager.contentsOfDirectory(
at: appIconSetURL,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]
)
.filter { $0.pathExtension.lowercased() == "png" }
.filter { primaryIconFilenames.contains($0.lastPathComponent) }
for pngURL in pngURLs {
try flattenPNG(at: pngURL, background: background)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 790 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

View file

@ -1,116 +1,38 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "AppIcon-20x20@2x.png",
"scale" : "2x"
"filename" : "AppIcon-Light.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "AppIcon-20x20@3x.png",
"scale" : "3x"
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "AppIcon-Dark.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "AppIcon-29x29@2x-1.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "AppIcon-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "AppIcon-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "AppIcon-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "AppIcon-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "AppIcon-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "AppIcon-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "AppIcon-20x20@2x-1.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "AppIcon-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "AppIcon-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "AppIcon-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "AppIcon-40x40@2x-1.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "AppIcon-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "AppIcon-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "AppIcon-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "AppIcon-512@2x.png",
"scale" : "1x"
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"filename" : "AppIcon-Tinted.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
"author" : "xcode",
"version" : 1
}
}
}

View file

@ -67,4 +67,4 @@
</dict>
</array>
</dict>
</plist>
</plist>