项目文件夹

文件
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

911 行
31 KiB
Swift

import AppKit
import SwiftUI
import MacToolsPluginKit
struct DeviceBatteryComponentView: View {
@ObservedObject var viewModel: DeviceBatteryViewModel
@ObservedObject var store: DeviceBatteryStore
let localization: PluginLocalization
let openSettings: () -> Void
var body: some View {
Group {
if visibleItems.isEmpty {
emptyState
} else {
switch store.layoutMode {
case .grid:
DeviceBatteryListCard(
items: Array(visibleItems.prefix(DeviceBatteryLayout.maximumListItems)),
totalCount: visibleItems.count,
localization: localization
)
case .list:
DeviceBatteryGaugeGrid(
items: Array(visibleItems.prefix(DeviceBatteryLayout.maximumGaugeItems)),
totalCount: visibleItems.count,
localization: localization
)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
private var visibleItems: [DeviceBatteryItem] {
viewModel.snapshot.visibleItems
}
private var emptyState: some View {
VStack(spacing: 8) {
Image(systemName: viewModel.snapshot.accessState.isError ? "exclamationmark.triangle" : "battery.0percent")
.font(.system(size: 24, weight: .semibold))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(viewModel.snapshot.accessState.isError ? Color(nsColor: .systemOrange) : Color.secondary)
Text(emptyTitle)
.font(.system(size: 13, weight: .semibold))
.lineLimit(1)
.minimumScaleFactor(0.8)
Text(emptySubtitle)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
if viewModel.snapshot.accessState == .permissionDenied {
Button(action: openSettings) {
Label(
localization.string("empty.openSettings", defaultValue: "打开系统设置"),
systemImage: "gearshape"
)
.font(.system(size: 11, weight: .semibold))
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding(12)
.frame(maxWidth: .infinity)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(DeviceBatteryCardBackground(cornerRadius: DeviceBatteryLayout.cornerRadius))
}
private var emptyTitle: String {
switch viewModel.snapshot.accessState {
case .permissionDenied:
return localization.string("empty.title.permissionDenied", defaultValue: "需要输入监控权限")
case .scanning:
return localization.string("empty.title.scanning", defaultValue: "正在读取电量")
case .failed:
return localization.string("empty.title.failed", defaultValue: "读取失败")
case .idle, .ready, .noDevices:
return localization.string("empty.title.noDevices", defaultValue: "暂无设备电量")
}
}
private var emptySubtitle: String {
switch viewModel.snapshot.accessState {
case .permissionDenied:
return localization.string("empty.subtitle.permissionDenied", defaultValue: "授权后可读取厂商 HID 鼠标")
case .failed(let message):
return message
case .scanning:
return localization.string("empty.subtitle.scanning", defaultValue: "正在查询系统电源与蓝牙设备")
case .idle, .ready, .noDevices:
return localization.string("empty.subtitle.noDevices", defaultValue: "连接蓝牙设备或厂商 HID 鼠标")
}
}
}
enum DeviceBatteryComponentLayout {
static let width = 4
static let cornerRadius: CGFloat = PluginComponentPanelLayoutMetrics.cardCornerRadius
static let horizontalPadding: CGFloat = 12
static let rowHeight: CGFloat = 34
static let overflowHeight: CGFloat = 20
static let rowIconWidth: CGFloat = 26
static let percentWidth: CGFloat = 38
static let batteryWidth: CGFloat = 23
static let cardVerticalPadding: CGFloat = 6
static let gaugeHorizontalPadding: CGFloat = 12
static let gaugeVerticalPadding: CGFloat = 10
static let maximumListItems = 6
static let maximumGaugeItems = 8
static let ringSpan: CGFloat = 0.78
static let ringRotation: Double = 129.6
static let ringTrackLineWidthScale: CGFloat = 1.15
static let poweredRingLineWidthScale: CGFloat = 1.38
static func contentHeight(mode: DeviceBatteryLayoutMode, visibleItemCount: Int) -> CGFloat {
guard visibleItemCount > 0 else {
return 116
}
switch mode {
case .grid:
return listCardHeight(totalCount: visibleItemCount)
case .list:
return gaugeCardHeight(totalCount: visibleItemCount)
}
}
static func spanHeight(
mode: DeviceBatteryLayoutMode,
visibleItemCount: Int,
metrics: PluginComponentPanelLayoutMetrics = .default
) -> Int {
metrics.heightSpan(
fittingContentHeight: contentHeight(
mode: mode,
visibleItemCount: visibleItemCount
)
)
}
static func listCardHeight(totalCount: Int) -> CGFloat {
let displayCount = min(max(totalCount, 1), maximumListItems)
return cardVerticalPadding * 2
+ CGFloat(displayCount) * rowHeight
+ CGFloat(max(displayCount - 1, 0))
+ overflowHeightIfNeeded(totalCount: totalCount, displayedCount: displayCount)
}
static func gaugeCardHeight(totalCount: Int) -> CGFloat {
let displayCount = min(max(totalCount, 1), maximumGaugeItems)
let tileSize = gaugeTileSize(for: displayCount)
let rows = gaugeRowCount(totalCount: totalCount)
let rowSpacing = gaugeRowSpacing(for: displayCount)
return gaugeVerticalPadding * 2
+ CGFloat(rows) * (tileSize + gaugePercentHeight)
+ CGFloat(max(rows - 1, 0)) * rowSpacing
}
static func gaugeColumnCount(for itemCount: Int) -> Int {
switch itemCount {
case 0...1:
return 1
case 2:
return 2
case 3:
return 3
default:
return 4
}
}
static func gaugeRowCount(totalCount: Int) -> Int {
let displayCount = min(max(totalCount, 1), maximumGaugeItems)
let cellCount = displayCount + (totalCount > displayCount ? 1 : 0)
let columns = gaugeColumnCount(for: displayCount)
return Int(ceil(Double(cellCount) / Double(columns)))
}
static func gaugeTileSize(for itemCount: Int) -> CGFloat {
switch itemCount {
case 0...2:
return 66
case 3...4:
return 58
default:
return 50
}
}
static func gaugeLineWidth(for tileSize: CGFloat) -> CGFloat {
max(4, tileSize * 0.078)
}
static let gaugePercentHeight: CGFloat = 20
static func gaugeRowSpacing(for itemCount: Int) -> CGFloat {
itemCount > 4 ? 8 : 6
}
static func overflowHeightIfNeeded(totalCount: Int, displayedCount: Int) -> CGFloat {
totalCount > displayedCount ? overflowHeight + 1 : 0
}
}
private typealias DeviceBatteryLayout = DeviceBatteryComponentLayout
private struct DeviceBatteryListCard: View {
let items: [DeviceBatteryItem]
let totalCount: Int
let localization: PluginLocalization
var body: some View {
VStack(spacing: 0) {
ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
DeviceBatteryNativeRow(
item: item,
rowHeight: DeviceBatteryLayout.rowHeight,
showsDetail: false,
localization: localization
)
if index < items.count - 1 {
DeviceBatteryDivider()
}
}
if totalCount > items.count {
DeviceBatteryDivider()
DeviceBatteryOverflowRow(count: totalCount - items.count, localization: localization)
}
}
.padding(.vertical, DeviceBatteryLayout.cardVerticalPadding)
.frame(maxWidth: .infinity)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(DeviceBatteryCardBackground(cornerRadius: DeviceBatteryLayout.cornerRadius))
}
}
private struct DeviceBatteryGaugeGrid: View {
let items: [DeviceBatteryItem]
let totalCount: Int
let localization: PluginLocalization
var body: some View {
Group {
LazyVGrid(columns: columns, spacing: rowSpacing) {
ForEach(items) { item in
DeviceBatteryGaugeTile(
item: item,
tileSize: tileSize,
lineWidth: DeviceBatteryLayout.gaugeLineWidth(for: tileSize),
localization: localization
)
.frame(maxWidth: .infinity)
}
if totalCount > items.count {
Text("+\(totalCount - items.count)")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.frame(width: tileSize, height: tileSize + DeviceBatteryLayout.gaugePercentHeight)
}
}
.padding(.horizontal, DeviceBatteryLayout.gaugeHorizontalPadding)
.padding(.vertical, DeviceBatteryLayout.gaugeVerticalPadding)
.frame(maxWidth: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(DeviceBatteryCardBackground(cornerRadius: DeviceBatteryLayout.cornerRadius))
}
private var tileSize: CGFloat {
DeviceBatteryLayout.gaugeTileSize(for: items.count)
}
private var rowSpacing: CGFloat {
DeviceBatteryLayout.gaugeRowSpacing(for: items.count)
}
private var columns: [GridItem] {
Array(
repeating: GridItem(.flexible(minimum: tileSize), spacing: items.count > 4 ? 6 : 8),
count: DeviceBatteryLayout.gaugeColumnCount(for: items.count)
)
}
}
private struct DeviceBatteryGaugeTile: View {
let item: DeviceBatteryItem
let tileSize: CGFloat
let lineWidth: CGFloat
let localization: PluginLocalization
var body: some View {
VStack(spacing: 2) {
ZStack {
DeviceBatteryRing(
item: item,
size: tileSize,
lineWidth: lineWidth,
localization: localization
)
Image(systemName: deviceSymbolName(for: item))
.font(.system(size: tileSize * 0.32, weight: .regular))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(iconTint)
.frame(width: tileSize * 0.54, height: tileSize * 0.48)
}
.frame(width: tileSize, height: tileSize)
.overlay {
if chargingSymbolName(for: item) != nil {
let badgeSize = max(14, tileSize * 0.24)
DeviceBatteryChargingBadge(
systemName: "bolt.fill",
color: batteryTint(for: item),
size: badgeSize
)
.position(x: tileSize / 2, y: chargingBadgeCenterY)
}
}
Text(DeviceBatteryFormatter.percent(item.clampedLevel))
.font(.system(size: max(10, tileSize * 0.18), weight: .regular, design: .rounded))
.foregroundStyle(.primary)
.monospacedDigit()
.lineLimit(1)
.minimumScaleFactor(0.7)
}
.frame(width: tileSize, height: tileSize + DeviceBatteryLayout.gaugePercentHeight)
.compositingGroup()
.help(helpText(for: item, localization: localization))
}
private var iconTint: Color {
chargingSymbolName(for: item) == nil
? Color.primary.opacity(0.58)
: batteryTint(for: item).opacity(0.84)
}
private var chargingBadgeCenterY: CGFloat {
ringPathInset
}
private var ringPathInset: CGFloat {
lineWidth * DeviceBatteryLayout.poweredRingLineWidthScale / 2
}
}
private struct DeviceBatteryChargingBadge: View {
let systemName: String
let color: Color
let size: CGFloat
var body: some View {
ZStack {
Circle()
.fill(Color.white)
Image(systemName: systemName)
.font(.system(size: max(7.2, size * 0.54), weight: .black))
.foregroundStyle(color)
}
.frame(width: size, height: size)
.accessibilityHidden(true)
}
}
private struct DeviceBatteryRing: View {
let item: DeviceBatteryItem
let size: CGFloat
let lineWidth: CGFloat
var localization: PluginLocalization = PluginLocalization(bundle: .main)
var body: some View {
ZStack {
Circle()
.inset(by: ringPathInset)
.trim(from: 0, to: DeviceBatteryLayout.ringSpan)
.stroke(
Color.primary.opacity(0.12),
style: StrokeStyle(
lineWidth: lineWidth * DeviceBatteryLayout.ringTrackLineWidthScale,
lineCap: .round,
lineJoin: .round
)
)
if showsPoweredOverlay {
Circle()
.inset(by: ringPathInset)
.trim(from: 0, to: DeviceBatteryLayout.ringSpan)
.stroke(
batteryTint(for: item).opacity(0.12),
style: StrokeStyle(
lineWidth: lineWidth * DeviceBatteryLayout.poweredRingLineWidthScale,
lineCap: .round,
lineJoin: .round
)
)
if progressSpan > 0.002, progress < 0.995 {
Circle()
.inset(by: ringPathInset)
.trim(from: max(progressSpan - 0.002, 0), to: max(progressSpan - 0.0004, 0))
.stroke(
batteryTint(for: item),
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)
)
.shadow(color: batteryTint(for: item).opacity(0.32), radius: lineWidth * 0.7)
.clipShape(
Circle()
.inset(by: ringPathInset)
.trim(from: 0, to: DeviceBatteryLayout.ringSpan)
.stroke(style: StrokeStyle(lineWidth: lineWidth * 1.4, lineCap: .round, lineJoin: .round))
)
}
}
Circle()
.inset(by: ringPathInset)
.trim(from: 0, to: progressSpan)
.stroke(
batteryTint(for: item),
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)
)
}
.rotationEffect(.degrees(DeviceBatteryLayout.ringRotation))
.frame(width: size, height: size)
.accessibilityLabel(
localization.format(
"accessibility.ring",
defaultValue: "%@,%@",
item.name,
DeviceBatteryFormatter.percent(item.clampedLevel)
)
)
}
private var progress: CGFloat {
CGFloat(item.clampedLevel ?? 0) / 100
}
private var progressSpan: CGFloat {
DeviceBatteryLayout.ringSpan * progress
}
private var showsPoweredOverlay: Bool {
chargingSymbolName(for: item) != nil
}
private var ringPathInset: CGFloat {
lineWidth * DeviceBatteryLayout.poweredRingLineWidthScale / 2
}
}
private struct DeviceBatteryDivider: View {
var body: some View {
Divider()
.padding(.leading, DeviceBatteryLayout.horizontalPadding + DeviceBatteryLayout.rowIconWidth + 10)
.padding(.trailing, DeviceBatteryLayout.horizontalPadding)
}
}
private struct DeviceBatteryOverflowRow: View {
let count: Int
let localization: PluginLocalization
var body: some View {
Text(localization.format("overflow.moreDevices", defaultValue: "还有 %d 台设备", count))
.font(.caption2.weight(.medium))
.foregroundStyle(.tertiary)
.frame(maxWidth: .infinity)
.frame(height: DeviceBatteryLayout.overflowHeight)
}
}
private struct DeviceBatteryNativeRow: View {
let item: DeviceBatteryItem
let rowHeight: CGFloat
var showsDetail = false
var compact = false
var prominent = false
var localization: PluginLocalization = PluginLocalization(bundle: .main)
var body: some View {
HStack(spacing: compact ? 8 : 10) {
Image(systemName: deviceSymbolName(for: item))
.font(.system(size: iconSize, weight: .regular))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(Color.primary.opacity(iconOpacity))
.frame(width: DeviceBatteryLayout.rowIconWidth, height: rowHeight)
VStack(alignment: .leading, spacing: showsDetail ? 2 : 0) {
Text(item.name)
.font(titleFont)
.foregroundStyle(.primary)
.lineLimit(1)
.truncationMode(.tail)
.minimumScaleFactor(0.72)
if showsDetail {
Text(deviceDetailText(for: item, localization: localization))
.font(.caption2.weight(.medium))
.foregroundStyle(.tertiary)
.lineLimit(1)
.truncationMode(.tail)
.minimumScaleFactor(0.7)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.layoutPriority(1)
Text(DeviceBatteryFormatter.percent(item.clampedLevel))
.font(percentFont)
.foregroundStyle(item.clampedLevel ?? 100 <= 10 ? Color(nsColor: .systemRed) : .primary)
.monospacedDigit()
.lineLimit(1)
.minimumScaleFactor(0.75)
.frame(width: DeviceBatteryLayout.percentWidth, alignment: .trailing)
DeviceBatterySystemBattery(item: item)
.frame(width: DeviceBatteryLayout.batteryWidth, height: 14)
}
.padding(.horizontal, DeviceBatteryLayout.horizontalPadding)
.frame(height: rowHeight)
.help(helpText(for: item, localization: localization))
}
private var titleFont: Font {
if prominent {
return .body.weight(.semibold)
}
return compact ? .caption.weight(.medium) : .subheadline.weight(.medium)
}
private var percentFont: Font {
if prominent {
return .subheadline
}
return compact
? .caption2
: .caption
}
private var iconSize: CGFloat {
if prominent {
return 21
}
return compact ? 15 : 17
}
private var iconOpacity: Double {
prominent ? 0.64 : 0.56
}
}
private struct DeviceBatterySystemBattery: View {
let item: DeviceBatteryItem
var body: some View {
HStack(spacing: 1) {
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 1.9, style: .continuous)
.stroke(Color.primary.opacity(0.30), lineWidth: 0.85)
.frame(width: 18, height: 8.2)
RoundedRectangle(cornerRadius: 1.2, style: .continuous)
.fill(batteryTint(for: item))
.frame(width: fillWidth, height: 4.8)
.padding(.leading, 1.9)
.opacity(item.clampedLevel == nil ? 0.18 : 0.82)
if chargingSymbolName(for: item) != nil {
Image(systemName: "bolt.fill")
.font(.system(size: 5.1, weight: .black))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.white)
.frame(width: 18, height: 8.2)
}
}
RoundedRectangle(cornerRadius: 0.8, style: .continuous)
.fill(Color.primary.opacity(0.30))
.frame(width: 1.3, height: 3.6)
}
.accessibilityLabel(DeviceBatteryFormatter.percent(item.clampedLevel))
}
private var fillWidth: CGFloat {
guard let level = item.clampedLevel else {
return 3
}
return max(2.8, 14.2 * CGFloat(level) / 100)
}
}
private struct DeviceBatteryCardBackground: View {
let cornerRadius: CGFloat
var body: some View {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(Color.primary.opacity(0.045))
}
}
private func batteryTint(for item: DeviceBatteryItem?) -> Color {
guard let item else {
return Color(nsColor: .systemGreen)
}
if item.chargeState == .charging || item.chargeState == .charged {
return Color(nsColor: .systemGreen)
}
guard let level = item.clampedLevel else {
return Color(nsColor: .systemBlue)
}
if level <= 20 {
return Color(nsColor: .systemRed)
}
if level <= 35 {
return Color(nsColor: .systemOrange)
}
return Color(nsColor: .systemGreen)
}
private func chargingSymbolName(for item: DeviceBatteryItem) -> String? {
switch item.chargeState {
case .charging, .charged:
return "bolt.fill"
case .plugged:
return "powerplug.fill"
case .unknown, .normal, .invalid:
return nil
}
}
private func batterySymbolName(for item: DeviceBatteryItem) -> String {
if item.chargeState == .charging || item.chargeState == .charged || item.chargeState == .plugged {
return "battery.100percent.bolt"
}
guard let level = item.clampedLevel else {
return "battery.0percent"
}
switch level {
case 76...100:
return "battery.100percent"
case 51...75:
return "battery.75percent"
case 26...50:
return "battery.50percent"
case 11...25:
return "battery.25percent"
default:
return "battery.0percent"
}
}
private func deviceDetailText(
for item: DeviceBatteryItem,
localization: PluginLocalization = PluginLocalization(bundle: .main)
) -> String {
if let detail = item.detail, !detail.isEmpty {
return detail
}
if let model = item.model, !model.isEmpty, model != item.name {
return model
}
return item.chargeState == .normal
? localization.string("deviceDetail.connected", defaultValue: "已连接")
: item.chargeState.title(localization: localization)
}
func deviceSymbolName(for item: DeviceBatteryItem) -> String {
let haystack = [
item.name,
item.model,
item.detail,
item.parentName,
item.kind.title()
]
.compactMap { $0?.lowercased() }
.joined(separator: " ")
switch item.kind {
case .internalBattery:
if containsAny(haystack, ["macbook", "mac book", "book"]) {
return "macbook"
}
if containsAny(haystack, ["mac mini", "macmini", "mini"]) {
return "macmini"
}
if containsAny(haystack, ["imac"]) {
return "desktopcomputer"
}
return "desktopcomputer.and.macbook"
case .phone:
return "iphone.gen2"
case .tablet:
return "ipad"
case .mediaPlayer:
return "ipodtouch"
case .watch:
return "applewatch"
case .spatialComputer:
return "visionpro"
case .rapooMouse:
return "computermouse.fill"
case .airPodsPart:
let ownName = item.name.lowercased()
if ownName.contains("case") || ownName.contains("充电盒") {
return airPodsSymbolName(in: haystack, part: .case)
}
if ownName.contains("左耳") || ownName.contains("left") || ownName.contains("🄻") {
return airPodsSymbolName(in: haystack, part: .left)
}
if ownName.contains("右耳") || ownName.contains("right") || ownName.contains("🅁") {
return airPodsSymbolName(in: haystack, part: .right)
}
return airPodsSymbolName(in: haystack, part: .all)
case .bluetooth, .magicAccessory, .other:
if containsAny(haystack, ["iphone", "phone", "mobile phone", "手机"]) {
return "iphone.gen2"
}
if containsAny(haystack, ["ipad", "tablet", "平板"]) {
return "ipad"
}
if containsAny(haystack, ["watch", "手表"]) {
return "applewatch"
}
if haystack.contains("vision") {
return "visionpro"
}
if containsAny(haystack, ["macbook", "mac book", "notebook", "laptop"]) {
return "macbook"
}
if containsAny(haystack, ["mac mini", "macmini"]) {
return "macmini"
}
if containsAny(haystack, ["imac", "desktop computer"]) {
return "desktopcomputer"
}
if containsAny(haystack, ["magic mouse", "magicmouse"]) {
return "magicmouse.fill"
}
if containsAny(haystack, ["mouse", "pointing", "鼠标", "mx anywhere", "mx master", "razer"]) {
return "computermouse.fill"
}
if containsAny(haystack, ["keyboard", "键盘", "hhkb", "niz"]) {
return "keyboard.fill"
}
if containsAny(haystack, ["trackpad", "touchpad", "触控板"]) {
return "rectangle.and.hand.point.up.left.fill"
}
if containsAny(haystack, ["airpods"]) {
return airPodsSymbolName(in: haystack, part: .all)
}
if containsAny(haystack, ["beats", "headphone", "headphones", "headset", "earbud", "earbuds", "耳机"]) {
return "headphones"
}
if containsAny(haystack, ["gamepad", "controller", "joystick", "手柄"]) {
return "gamecontroller.fill"
}
if containsAny(haystack, ["speaker", "音箱", "扬声器"]) {
return "hifispeaker.fill"
}
if containsAny(haystack, ["printer", "打印机"]) {
return "printer.fill"
}
if containsAny(haystack, ["camera", "摄像头", "相机"]) {
return "camera.fill"
}
return item.kind.iconName
}
}
private func containsAny(_ text: String, _ needles: [String]) -> Bool {
needles.contains { text.contains($0) }
}
private enum DeviceBatteryAirPodsPart {
case all
case left
case right
case `case`
}
private func airPodsSymbolName(
in haystack: String,
part: DeviceBatteryAirPodsPart
) -> String {
if haystack.contains("airpods max") {
return availableSystemSymbolName(["airpods.max", "airpodsmax"])
}
if isAirPodsPro(in: haystack) {
switch part {
case .all:
return "airpodspro"
case .left:
return "airpodpro.left"
case .right:
return "airpodpro.right"
case .case:
return "airpodspro.chargingcase.wireless"
}
}
if isAirPodsGeneration(4, in: haystack) {
switch part {
case .all:
return availableSystemSymbolName(["airpods.gen4", "airpods"])
case .left:
return availableSystemSymbolName(["airpods.gen4.left", "airpod.left"])
case .right:
return availableSystemSymbolName(["airpods.gen4.right", "airpod.right"])
case .case:
return availableSystemSymbolName(["airpods.gen4.chargingcase.wireless", "airpods.chargingcase"])
}
}
if isAirPodsGeneration(3, in: haystack) {
switch part {
case .all:
return availableSystemSymbolName(["airpods.gen3", "airpods"])
case .left:
return availableSystemSymbolName(["airpod.gen3.left", "airpod.left"])
case .right:
return availableSystemSymbolName(["airpod.gen3.right", "airpod.right"])
case .case:
return availableSystemSymbolName(["airpods.gen3.chargingcase.wireless", "airpods.chargingcase"])
}
}
switch part {
case .all:
return "airpods"
case .left:
return "airpod.left"
case .right:
return "airpod.right"
case .case:
return "airpods.chargingcase"
}
}
private func isAirPodsPro(in haystack: String) -> Bool {
haystack.contains("airpods pro") || haystack.contains("airpodspro")
}
private func isAirPodsGeneration(_ generation: Int, in haystack: String) -> Bool {
let compact = haystack.replacingOccurrences(of: " ", with: "")
return haystack.contains("airpods \(generation)")
|| haystack.contains("airpods gen\(generation)")
|| haystack.contains("airpods gen \(generation)")
|| haystack.contains("airpods.gen\(generation)")
|| compact.contains("airpods\(generation)")
|| compact.contains("第\(generation)代")
}
private func availableSystemSymbolName(_ candidates: [String]) -> String {
for candidate in candidates {
if NSImage(systemSymbolName: candidate, accessibilityDescription: nil) != nil {
return candidate
}
}
return candidates.last ?? "airpods"
}
private func helpText(
for item: DeviceBatteryItem,
localization: PluginLocalization = PluginLocalization(bundle: .main)
) -> String {
var lines = [
item.name,
localization.format(
"help.levelAndState",
defaultValue: "%@ · %@",
DeviceBatteryFormatter.percent(item.clampedLevel),
item.chargeState.title(localization: localization)
),
localization.format("help.source", defaultValue: "来源:%@", item.source)
]
if let parentName = item.parentName {
lines.append(localization.format("help.parent", defaultValue: "关联:%@", parentName))
}
if let lastUpdated = item.lastUpdated {
lines.append(
localization.format(
"help.updated",
defaultValue: "更新:%@",
DeviceBatteryFormatter.time(lastUpdated, localization: localization)
)
)
}
return lines.joined(separator: "\n")
}