Files
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 lines
31 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}