From f4bc166ff20a236c89f5be0f95e3b4406abce611 Mon Sep 17 00:00:00 2001 From: Qijia Liu Date: Wed, 4 Nov 2026 23:49:47 -0500 Subject: [PATCH] Show app details with linked build revisions --- assets/Localizable.xcstrings | 262 +++++++++++++++++++++++++++++++- src/AppCatalog.swift | 4 +- src/AppConfiguration.swift | 8 + src/CMakeLists.txt | 1 + src/CatalogAppDetailView.swift | 178 ++++++++++++++++++++++ src/CatalogBrowserView.swift | 84 +++++----- src/CatalogPresentation.swift | 2 +- tests/AppCatalogTests.swift | 2 +- tests/CatalogLoadingTests.swift | 15 ++ tests/LocalizationTests.swift | 11 ++ 10 files changed, 511 insertions(+), 56 deletions(-) create mode 100644 src/CatalogAppDetailView.swift diff --git a/assets/Localizable.xcstrings b/assets/Localizable.xcstrings index 7ca3a2f..8790ed5 100644 --- a/assets/Localizable.xcstrings +++ b/assets/Localizable.xcstrings @@ -129,18 +129,18 @@ } } }, - "Apps": { + "Available Versions": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Apps" + "value": "Available Versions" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "应用" + "value": "可用版本" } } } @@ -193,6 +193,54 @@ } } }, + "Build Details": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Build Details" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "构建详情" + } + } + } + }, + "Build Release": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Build Release" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "构建发布页" + } + } + } + }, + "Bundle ID": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bundle ID" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "包标识符" + } + } + } + }, "Catalog Unavailable": { "localizations": { "en": { @@ -497,6 +545,38 @@ } } }, + "Done": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "完成" + } + } + } + }, + "Download Size": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download Size" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载大小" + } + } + } + }, "Every app in the catalog is already installed on this simulator.": { "localizations": { "en": { @@ -545,6 +625,38 @@ } } }, + "License": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "License" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "许可证" + } + } + } + }, + "License refers to the upstream project’s main license. Some components or resources may use other licenses.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "License refers to the upstream project’s main license. Some components or resources may use other licenses." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "许可证指上游项目的主要许可证。部分组件或资源可能使用其他许可证。" + } + } + } + }, "Loading Apps…": { "localizations": { "en": { @@ -801,6 +913,22 @@ } } }, + "Published": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Published" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "发布时间" + } + } + } + }, "Published apps will appear here.": { "localizations": { "en": { @@ -817,6 +945,22 @@ } } }, + "Recipe Revision": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recipe Revision" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "构建脚本提交" + } + } + } + }, "Refresh Catalog": { "localizations": { "en": { @@ -849,6 +993,22 @@ } } }, + "Requires": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Requires" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "系统要求" + } + } + } + }, "Runtime": { "localizations": { "en": { @@ -865,6 +1025,22 @@ } } }, + "SHA-256": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SHA-256" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "SHA-256" + } + } + } + }, "Search apps": { "localizations": { "en": { @@ -1025,6 +1201,38 @@ } } }, + "Source Repository": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Source Repository" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "源代码仓库" + } + } + } + }, + "Source Revision": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Source Revision" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "源代码提交" + } + } + } + }, "Start Simulator": { "localizations": { "en": { @@ -1153,6 +1361,22 @@ } } }, + "This build requires iOS %@ or later. It may not work on iOS %@.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This build requires iOS %@ or later. It may not work on iOS %@." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此构建需要 iOS %@ 或更高版本,在 iOS %@ 上可能无法正常运行。" + } + } + } + }, "Try Again": { "localizations": { "en": { @@ -1233,6 +1457,38 @@ } } }, + "Version": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Version" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "版本" + } + } + } + }, + "View app details and versions": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "View app details and versions" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "查看应用详情和版本" + } + } + } + }, "Xcode did not provide a valid developer directory.": { "localizations": { "en": { diff --git a/src/AppCatalog.swift b/src/AppCatalog.swift index fb9afd9..67dd7e4 100644 --- a/src/AppCatalog.swift +++ b/src/AppCatalog.swift @@ -38,7 +38,7 @@ struct CatalogApp: Identifiable, Hashable, Sendable { } } -struct CatalogVersion: Hashable, Sendable { +struct CatalogVersion: Identifiable, Hashable, Sendable { let version: String let minSimulatorOS: String let sha256: String @@ -47,6 +47,8 @@ struct CatalogVersion: Hashable, Sendable { let recipeRevision: String let time: Int64 + var id: String { version } + func isCompatible(with runtime: String) -> Bool { guard let minimum = CatalogVersionNumber(minSimulatorOS), let installed = CatalogVersionNumber(runtime) diff --git a/src/AppConfiguration.swift b/src/AppConfiguration.swift index 2ff9b50..0e97425 100644 --- a/src/AppConfiguration.swift +++ b/src/AppConfiguration.swift @@ -9,6 +9,14 @@ enum AppConfiguration { buildsRepositoryURL.appending(path: "releases/download/latest/index.json") } + static func releaseURL(appID: String, version: String) -> URL { + buildsRepositoryURL.appending(path: "releases/tag").appending(component: "\(appID)/\(version)") + } + + static func commitURL(repository: URL, revision: String) -> URL { + repository.appending(path: "commit").appending(component: revision) + } + static func assetURL(appID: String, version: String, filename: String) -> URL { buildsRepositoryURL.appending(path: "releases/download") .appending(component: "\(appID)/\(version)").appending(component: filename) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index e52d7be..0f56feb 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -18,6 +18,7 @@ add_executable(SimStore MACOSX_BUNDLE CatalogStore.swift CatalogPresentation.swift CatalogBrowserView.swift + CatalogAppDetailView.swift SidebarView.swift NoSimulatorsView.swift MissingXcodeView.swift diff --git a/src/CatalogAppDetailView.swift b/src/CatalogAppDetailView.swift new file mode 100644 index 0000000..aed0db5 --- /dev/null +++ b/src/CatalogAppDetailView.swift @@ -0,0 +1,178 @@ +import SwiftUI + +@MainActor +struct CatalogAppDetailView: View { + let app: CatalogApp + let runtime: String + @State private var versionID: String + @Environment(\.dismiss) private var dismiss + + init(app: CatalogApp, runtime: String) { + self.app = app + self.runtime = runtime + _versionID = State(initialValue: app.recommendedVersion(for: runtime).version) + } + + private var version: CatalogVersion { + app.versions.first(where: { $0.version == versionID }) ?? app.recommendedVersion(for: runtime) + } + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + HStack(alignment: .top, spacing: 20) { + CatalogIcon(app: app, size: 80) + VStack(alignment: .leading, spacing: 8) { + Text(verbatim: app.name).font(.system(size: 26, weight: .bold)) + Text(verbatim: app.categoryTitle).font(.system(size: 12)).foregroundStyle(.secondary) + Text(verbatim: app.displayDescription).font(.system(size: 14)) + .fixedSize(horizontal: false, vertical: true) + } + } + + Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 16) { + GridRow { + Text("Version").foregroundStyle(.secondary) + Picker(selection: $versionID) { + ForEach(app.versions) { build in + Text(verbatim: build.version).tag(build.version) + } + } label: { + Text("Available Versions") + } + .labelsHidden() + .accessibilityIdentifier("catalog-version") + .frame(maxWidth: 240) + } + GridRow { + Text("Requires").foregroundStyle(.secondary) + Text(verbatim: version.minimumOSLabel) + } + GridRow { + Text("Download Size").foregroundStyle(.secondary) + Text( + verbatim: ByteCountFormatter.string(fromByteCount: version.size, countStyle: .file)) + } + GridRow { + Text("Published").foregroundStyle(.secondary) + Text( + Date(timeIntervalSince1970: TimeInterval(version.time)), + format: .dateTime.year().month().day()) + } + GridRow { + Text("License").foregroundStyle(.secondary) + Text(verbatim: app.license).textSelection(.enabled) + } + GridRow { + Text("Bundle ID").foregroundStyle(.secondary) + Text(verbatim: app.bundleID).font(.system(size: 11, design: .monospaced)) + .textSelection(.enabled) + } + } + .font(.system(size: 13)) + .frame(maxWidth: .infinity, alignment: .leading) + + if !version.isCompatible(with: runtime) { + Label { + Text( + "This build requires iOS \(version.minSimulatorOS) or later. It may not work on iOS \(runtime)." + ) + } icon: { + Image(systemName: "exclamationmark.triangle") + } + .font(.system(size: 12)) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } + + HStack(spacing: 20) { + Link(destination: app.repository) { + Label("Source Repository", systemImage: "curlybraces") + } + Link(destination: AppConfiguration.releaseURL(appID: app.id, version: version.version)) + { + Label("Build Release", systemImage: "arrow.up.right.square") + } + } + + DisclosureGroup("Build Details") { + VStack(alignment: .leading, spacing: 12) { + buildDetail( + "Source Revision", value: version.revision, + link: AppConfiguration.commitURL( + repository: app.repository, revision: version.revision)) + buildDetail( + "Recipe Revision", value: version.recipeRevision, + link: AppConfiguration.commitURL( + repository: AppConfiguration.buildsRepositoryURL, revision: version.recipeRevision + )) + buildDetail("SHA-256", value: version.sha256) + } + .padding(.top, 12) + } + .disclosureGroupStyle(DetailsDisclosureStyle()) + .font(.system(size: 12)) + + Text( + "License refers to the upstream project’s main license. Some components or resources may use other licenses." + ) + .font(.system(size: 11)).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(28) + } + Divider() + HStack { + Spacer() + Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) + } + .padding(20) + } + .frame(width: 580, height: 560) + } + + private func buildDetail(_ title: LocalizedStringKey, value: String, link: URL? = nil) + -> some View + { + VStack(alignment: .leading, spacing: 4) { + Text(title).foregroundStyle(.secondary) + Group { + if let link { + Link(destination: link) { Text(verbatim: value) } + } else { + Text(verbatim: value).textSelection(.enabled) + } + } + .font(.system(size: 10, design: .monospaced)) + .fixedSize(horizontal: false, vertical: true) + } + } +} + +private struct DetailsDisclosureStyle: DisclosureGroupStyle { + func makeBody(configuration: Configuration) -> some View { + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation { configuration.isExpanded.toggle() } + } label: { + HStack(spacing: 6) { + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .rotationEffect(.degrees(configuration.isExpanded ? 90 : 0)) + .accessibilityHidden(true) + configuration.label + Spacer(minLength: 0) + } + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if configuration.isExpanded { + configuration.content.padding(.leading, 16) + } + } + } +} diff --git a/src/CatalogBrowserView.swift b/src/CatalogBrowserView.swift index 27dc1d3..779497b 100644 --- a/src/CatalogBrowserView.swift +++ b/src/CatalogBrowserView.swift @@ -12,9 +12,9 @@ struct CatalogBrowserView: View { @State private var query = "" @State private var category = "" + @State private var selectedApp: CatalogApp? @State private var showsSimulator = false - @State private var visibleRow: Int? - @State private var gridHeight: CGFloat = 0 + @FocusState private var focusedAppID: String? private var categories: [String] { Array(Set(availableApps?.flatMap(\.categories) ?? [])).sorted() @@ -84,6 +84,9 @@ struct CatalogBrowserView: View { guard availableApps != nil else { return } if !category.isEmpty && !values.contains(category) { category = "" } } + .sheet(item: $selectedApp) { app in + CatalogAppDetailView(app: app, runtime: device.runtime) + } .popover(isPresented: $showsSimulator) { SimulatorDetailView( device: device, isStarting: isStarting, deviceManagerName: deviceManagerName, @@ -183,20 +186,24 @@ struct CatalogBrowserView: View { let columns = max(1, Int((geometry.size.width + 16) / 280)) let cardWidth = (geometry.size.width - 6 - CGFloat(columns - 1) * 16) / CGFloat(columns) let visibleApps = apps - let rowCount = (visibleApps.count + columns - 1) / columns - let rowHeight = gridHeight > 0 ? (gridHeight + 16) / CGFloat(rowCount) : geometry.size.height - let pageRows = max(1, Int(geometry.size.height / max(rowHeight, 1))) - ScrollView { - Grid(horizontalSpacing: 16, verticalSpacing: 16) { - ForEach(0..