diff --git a/.agents/notes/implemented/feature/2026-10-12-plugin-marketplace.md b/.agents/notes/implemented/feature/2026-10-12-plugin-marketplace.md index d90ec7910e..c9df1e6d1b 100644 --- a/.agents/notes/implemented/feature/2026-10-12-plugin-marketplace.md +++ b/.agents/notes/implemented/feature/2026-10-12-plugin-marketplace.md @@ -59,6 +59,17 @@ Lithe 的插件管理改成和 IntelliJ IDEA 一样的“插件市场 / 已安 **展示的版本。** Core 会跳过当前平台或插件 API 不满足的新版本,选择能安装的旧版本。详情里的版本、发布日期和支持平台都描述“点安装会装的那个版本”;最新版本不同时,单独一行注明它在当前 Lithe 或这台 Mac 上无法使用。 +**市场详情的分栏边界。** 市场和“已安装”使用不同的详情视图,必须分别验证。 +市场信息行和来源选择器有最小宽度;直接把它们放在纵向 ScrollView(滚动视口)里, +会让 SwiftUI 按内容宽度重新居中,拖窄后文字和图标就跨到分隔线左侧。 +市场视口现在先接受分栏给出的矩形,再以该宽度布局详情。正文最小宽度由现有 +标签列、间距、来源选择器和提示卡的尺寸推导;较窄时可横向滚动查看完整内容, +较宽时描述仍按可见宽度换行。短详情在高度变化后也保持顶部对齐。 +这里只测量视口的宽高,内容在测量闭包外构建;不在拖动中重读市场、重建模型或写偏好。 +正确做法是约束视口并保留内容的滚动可达性;不要只裁掉越界正文、把详情最小宽度 +传回外层撑宽设置窗口,或因为“已安装”测试通过就认为市场也通过。 +这是原生视图布局修改,没有新增运行时文件、缓存或安装资源写入。 + 正确做法:新增官方插件时登记到 `official-plugins.json`,改插件内容时同时升 `plugin.json` 的版本。 不要这样做:手工改 `index.json` 里的下载地址或 SHA-256,或者把插件包重新挂到 Lithe 的版本 Release 上。 @@ -93,6 +104,7 @@ Lithe 的插件管理改成和 IntelliJ IDEA 一样的“插件市场 / 已安 - `cargo test --manifest-path rust/lithe-core/Cargo.toml --lib marketplace_plugins`:索引校验、平台与 API 判断、命名空间、源地址解析、安装审查的标准答案。 - `./scripts/verify-shared-contracts.sh`:市场 fixture 与错误码登记。 +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginMarketplaceLayoutTests|PluginDetailLayoutTests|PluginSettingsLayoutTests'`:市场用内存索引驱动真实页面,覆盖中英文描述、深浅主题、窗口宽高变化、原生分隔条拖动、释放后刷新不移位,以及窄视口横向滚动到正文末端;单独保留“已安装”详情和路径控件回归。可用 `LITHE_MARKETPLACE_LAYOUT_CAPTURES` 导出市场原生视图截图;测试拥有并清理自己的偏好域和窗口。 - `node --test scripts/official-plugin-releases.test.mjs`:只为版本变化的插件生成标签,内容变化未升版本时失败;已发布未登记的版本会被重新登记。 - `node scripts/test-reuse-worktree-resources.mjs`:磁盘快照、安装 staging、版本和审批记录不出现在可复用列表,显式选择时在访问工作树或复制前拒绝。 - `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginMarketplace|PluginDiskInstallation|MacPluginDiskPackageImporter|MacPluginPackageDownloader|PluginManagementPresentation|PluginManager|PluginPackageStore'`:校验和先于解压、下载和展开上限、磁盘快照隔离、源与签名要求、完整程序审查、安装 staging/扫描/回滚摘要复查、来源保留及同 ID 不自动接管。过滤器只是运行入口,必须核对报告确实包含 `MacPluginDiskPackageImporterTests`、`PluginDiskInstallationTests` 和磁盘 FeatureModel 场景,不能以零匹配当作通过。 diff --git a/docs/assets/plugin-marketplace-layout/after-dark.png b/docs/assets/plugin-marketplace-layout/after-dark.png new file mode 100644 index 0000000000..ce6185aa21 Binary files /dev/null and b/docs/assets/plugin-marketplace-layout/after-dark.png differ diff --git a/docs/assets/plugin-marketplace-layout/after-light.png b/docs/assets/plugin-marketplace-layout/after-light.png new file mode 100644 index 0000000000..514b3c8495 Binary files /dev/null and b/docs/assets/plugin-marketplace-layout/after-light.png differ diff --git a/docs/assets/plugin-marketplace-layout/before-dark.png b/docs/assets/plugin-marketplace-layout/before-dark.png new file mode 100644 index 0000000000..28715eb59c Binary files /dev/null and b/docs/assets/plugin-marketplace-layout/before-dark.png differ diff --git a/docs/assets/plugin-marketplace-layout/before-light.png b/docs/assets/plugin-marketplace-layout/before-light.png new file mode 100644 index 0000000000..4cb57f2f87 Binary files /dev/null and b/docs/assets/plugin-marketplace-layout/before-light.png differ diff --git a/docs/plugin-marketplace-layout-visual-check.md b/docs/plugin-marketplace-layout-visual-check.md new file mode 100644 index 0000000000..35a45b2751 --- /dev/null +++ b/docs/plugin-marketplace-layout-visual-check.md @@ -0,0 +1,40 @@ +# 插件市场详情的分栏边界 + +市场详情在窄面板下曾越过分隔线,遮住左侧列表。原因是详情的信息行、来源 +选择器和提示卡有最小宽度,纵向滚动容器按内容宽度布局后,整个详情向左偏移。 +“已安装”的详情是另一套视图,因此之前的已安装布局回归没有覆盖这个入口。 + +修复后,市场详情的视口接受分栏分配的宽度。正文在宽面板内换行,在极窄面板内 +保留完整内容并允许横向滚动;图标、名称和说明均不会绘制到分隔线左侧。 +短正文在窗口高度改变后保持顶部对齐。 + +## 同一页面的前后对比 + +图像来自 `PluginMarketplaceLayoutTests` 驱动的真实市场页,使用内存索引、两个 +示例仓库和中文插件描述,没有访问网络或执行安装。窗口为 585 × 700pt,左栏 +为 420pt,详情视口为 160pt。原布局取自 preview `1fba19412`,仅补充与当前相同的 +辅助功能标识,用同一份测试和断言验证;原布局失败,当前布局通过。 + +| 主题 | 修复前 | 修复后 | +| --- | --- | --- | +| 浅色 | ![浅色市场详情越过分隔线](assets/plugin-marketplace-layout/before-light.png) | ![浅色市场详情留在右侧](assets/plugin-marketplace-layout/after-light.png) | +| 深色 | ![深色市场详情越过分隔线](assets/plugin-marketplace-layout/before-dark.png) | ![深色市场详情留在右侧](assets/plugin-marketplace-layout/after-dark.png) | + +修复后截图处于横向滚动起点,右侧未显示的信息仍在同一滚动文档里。回归另验证 +滚动可到达文档末端。图像是隔离测试宿主的原生视图绘制,不代表完整发行应用、 +真实桌面合成或用户实机验收;界面固定文字使用测试宿主的语言资源。 + +## 复现和验证 + +```sh +LITHE_MARKETPLACE_LAYOUT_CAPTURES="$PWD/.artifacts/marketplace-layout-check" \ + ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- \ + --filter 'PluginMarketplaceLayoutTests|PluginDetailLayoutTests|PluginSettingsLayoutTests' +``` + +市场回归覆盖英文/中文描述、深浅主题、窗口宽高变化、原生鼠标拖动、释放后刷新 +不移位、极窄视口的横向滚动与顶部对齐。已安装详情和路径控件保留各自的回归。 +测试使用独占的 UserDefaults 偏好域,并在结束后清理偏好域和窗口。 + +这些 PNG 是静态文档材料,不由产品读取,不进入 app bundle、安装目录或工作树 +资源复用清单。产品修改没有新增运行时文件、下载入口或缓存。 diff --git a/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift b/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift index 6421e96622..0a65b1d76e 100644 --- a/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift +++ b/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift @@ -189,17 +189,22 @@ struct PluginMarketplaceView: View { @ViewBuilder private var detail: some View { if let listing = selectedListing, let offer = feature.offer(for: listing) { - ScrollView { - PluginMarketplaceDetail( - feature: feature, - listing: listing, - offer: offer, - install: { install(offer) }, - uninstall: { uninstall(listing.pluginID) } - ) + let content = PluginMarketplaceDetail( + feature: feature, listing: listing, offer: offer, + install: { install(offer) }, uninstall: { uninstall(listing.pluginID) } + ) + // Measure only this viewport. Its content must not widen or center + // the pane across the divider when a row cannot shrink further. + GeometryReader { geometry in + ScrollView([.horizontal, .vertical]) { + content.frame(width: max(geometry.size.width, PluginMarketplaceDetail.minimumWidth), alignment: .leading) + .frame(minHeight: geometry.size.height, alignment: .topLeading) + } + .litheScrollViewChrome() } - .litheScrollViewChrome() + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) .background(LitheTheme.settingsSurface) + .clipped() } else { VStack(spacing: 10) { PluginMarketplaceIcon(icons: feature.icons, entry: nil, size: 36) @@ -236,6 +241,13 @@ struct PluginMarketplaceView: View { /// shows (version, license, release date, platforms, links), and the notice /// about who maintains it. private struct PluginMarketplaceDetail: View { + private static let inset: CGFloat = 24 + private static let labelWidth: CGFloat = 96 + private static let metadataSpacing: CGFloat = 12 + private static let sourcePickerWidth: CGFloat = 180 + /// Keep the existing source selector and banner usable at the split's + /// smallest width; the viewport scrolls instead of clipping those controls. + static let minimumWidth = 2 * inset + max(LitheTheme.Banner.minimumWidth, labelWidth + metadataSpacing + sourcePickerWidth) @Environment(\.locale) private var locale @ObservedObject var feature: PluginMarketplaceFeatureModel let listing: PluginMarketplaceListing @@ -273,13 +285,14 @@ private struct PluginMarketplaceDetail: View { .font(LitheTheme.settingsFont) .foregroundStyle(LitheTheme.primaryText) .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("plugin-marketplace-description") metadata links maintainerNotice Spacer(minLength: 0) } - .padding(24) - .frame(maxWidth: .infinity, alignment: .leading) + .padding(Self.inset) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) } private var header: some View { @@ -292,6 +305,7 @@ private struct PluginMarketplaceDetail: View { Text(verbatim: entry.name) .font(LitheTheme.uiFont(size: 20, weight: .bold)) .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("plugin-marketplace-name") HStack(spacing: 8) { PluginTrustBadge(isOfficial: offer.source.isOfficial) Text(verbatim: "\(entry.author) · \(PluginMarketplacePresentation.repositoryName(entry.repository))") @@ -315,6 +329,7 @@ private struct PluginMarketplaceDetail: View { case .installable: Button(LocalizedStringKey("Install"), action: install) .buttonStyle(LitheActionButtonStyle(isPrimary: true)) + .accessibilityIdentifier("plugin-marketplace-install") case .updateAvailable(_, let available): Button(String(format: PluginMarketplacePresentation.localized("Update to %@", locale: locale), available), action: install) .buttonStyle(LitheActionButtonStyle(isPrimary: true)) @@ -459,7 +474,7 @@ private struct PluginMarketplaceDetail: View { set: { feature.selectedSourceIDs[listing.pluginID] = $0 } ), options: listing.offers.map(\.source.id), - width: 180, + width: Self.sourcePickerWidth, accessibilityLabel: "Install from repository", title: { id in listing.offers.first { $0.source.id == id } @@ -497,10 +512,10 @@ private struct PluginMarketplaceDetail: View { } private func metadataRow(_ title: String, @ViewBuilder value: () -> Content) -> some View { - HStack(alignment: .firstTextBaseline, spacing: 12) { + HStack(alignment: .firstTextBaseline, spacing: Self.metadataSpacing) { Text(LocalizedStringKey(title)) .foregroundStyle(LitheTheme.secondaryText) - .frame(width: 96, alignment: .leading) + .frame(width: Self.labelWidth, alignment: .leading) value() .textSelection(.enabled) } diff --git a/macos/Tests/LitheTests/PluginMarketplaceLayoutTests.swift b/macos/Tests/LitheTests/PluginMarketplaceLayoutTests.swift new file mode 100644 index 0000000000..073ecfe212 --- /dev/null +++ b/macos/Tests/LitheTests/PluginMarketplaceLayoutTests.swift @@ -0,0 +1,174 @@ +import AppKit +import LitheCoreContracts +import LitheModuleAPI +import SwiftUI +import Testing +@testable import Lithe + +@MainActor +@Suite("Marketplace split bounds", .serialized) +struct PluginMarketplaceLayoutTests { + @Test(arguments: ["en", "zh-Hans"], [ColorScheme.light, .dark]) + func marketplaceContentStaysOnItsSideDuringResizeAndDrag(language: String, scheme: ColorScheme) async throws { + let service = MarketplaceLayoutService() + let feature = PluginMarketplaceFeatureModel(service: service, pluginManager: MarketplaceLayoutManager()) + await feature.refreshIfNeeded() + let domain = "lithe-marketplace-layout-\(UUID().uuidString)" + let preferences = try #require(UserDefaults(suiteName: domain)) + defer { preferences.removePersistentDomain(forName: domain); feature.endInstallationSession() } + preferences.set(420.0, forKey: "lithe.settings.pluginMarketplaceListWidth") + let host = NSHostingView(rootView: PluginMarketplaceView(feature: feature) + .defaultAppStorage(preferences) + .environment(\.locale, Locale(identifier: language)).environment(\.colorScheme, scheme)) + host.sizingOptions = [] + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1100, height: 700), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: scheme == .dark ? .darkAqua : .aqua) + window.contentView = host + window.orderBack(nil) + defer { window.contentView = nil; window.close() } + for (width, height) in [(1100.0, 700.0), (900, 500), (700, 700), (585, 700), (900, 900)] { + window.setContentSize(NSSize(width: width, height: height)) + host.layoutSubtreeIfNeeded() + if width == 585 { try Self.capture(host, name: "narrow-\(language)-\(scheme == .dark ? "dark" : "light")") } + try await Self.checkBounds(host) + } + let handle = try #require(Self.descendants(host).compactMap { $0 as? SplitHandleInteractionView }.first) + for distance in [-180.0, 240, -80] { + let original = handle.convert(handle.bounds, to: host) + let origin = handle.convert(NSPoint(x: handle.bounds.midX, y: handle.bounds.midY), to: nil) + func event(_ type: NSEvent.EventType, distance: CGFloat = 0) throws -> NSEvent { + try #require(NSEvent.mouseEvent(with: type, location: NSPoint(x: origin.x + distance, y: origin.y), + modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil, + eventNumber: 0, clickCount: 1, pressure: type == .leftMouseUp ? 0 : 1)) + } + // Go through the product's native hit target and the window's drag + // delivery, rather than invoking a layout callback directly. + window.sendEvent(try event(.leftMouseDown)) + window.sendEvent(try event(.leftMouseDragged, distance: distance)) + window.sendEvent(try event(.leftMouseUp, distance: distance)) + try await Self.settle(host) { abs(handle.convert(handle.bounds, to: host).midX - original.midX) > 1 } + try await Self.checkBounds(host) + let moved = handle.convert(handle.bounds, to: host) + // A late feature refresh must not move the divider after release. + await feature.refreshAll() + host.layoutSubtreeIfNeeded() + #expect(abs(handle.convert(handle.bounds, to: host).midX - moved.midX) < 1) + try await Self.checkBounds(host) + } + try Self.capture(host, name: "marketplace-\(language)-\(scheme == .dark ? "dark" : "light")") + } + + private static func capture(_ host: NSView, name: String) throws { + if let directory = ProcessInfo.processInfo.environment["LITHE_MARKETPLACE_LAYOUT_CAPTURES"] { + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let output = URL(fileURLWithPath: directory, isDirectory: true) + try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + try #require(bitmap.representation(using: .png, properties: [:])) + .write(to: output.appendingPathComponent("\(name).png")) + } + } + + private static func checkBounds(_ host: NSView) async throws { + host.layoutSubtreeIfNeeded() + let handle = try #require(descendants(host).compactMap { $0 as? SplitHandleInteractionView }.first) + let divider = handle.convert(handle.bounds, to: host).midX + let expected = (host.window?.contentView?.bounds.width ?? host.bounds.width) + #expect(divider >= PluginManagementView.listMinimumWidth) + #expect(divider < expected - PluginManagementView.detailMinimumWidth) + let scrolls = descendants(host).compactMap { $0 as? NSScrollView } + let detail = try #require(scrolls.max { $0.convert($0.bounds, to: host).minX < $1.convert($1.bounds, to: host).minX }) + let viewport = detail.convert(detail.bounds, to: host) + #expect(abs(viewport.minX - divider - SplitHandleView.thickness / 2) < 1) + #expect(abs(viewport.maxX - host.bounds.maxX) < 1) + let document = try #require(detail.documentView) + let content = document.convert(document.bounds, to: host) + for id in ["plugin-marketplace-name", "plugin-marketplace-install", "plugin-marketplace-description"] { + let frame = try await AgentNativeControlTestSupport.frame(of: id, in: host) + #expect(frame.minX >= divider, "\(id) crosses divider \(divider): \(frame)") + #expect(frame.maxX <= content.maxX + 1, "\(id) overflows its scroll document: \(frame)") + if id == "plugin-marketplace-name" { + let top = host.isFlipped ? frame.minY - viewport.minY : viewport.maxY - frame.maxY + #expect(top < 40, "Short plugin details must stay at the top: \(top)") + } + } + for scroll in scrolls { + let frame = scroll.convert(scroll.bounds, to: host) + #expect(frame.minX >= -1 && frame.maxX <= host.bounds.maxX + 1) + } + // At 160pt the full source picker cannot fit. All content remains + // reachable, with the native clip view still confined to the detail. + if document.bounds.width > detail.contentView.bounds.width + 1 { + detail.contentView.scroll(to: NSPoint(x: document.bounds.maxX - detail.contentView.bounds.width, y: 0)) + detail.reflectScrolledClipView(detail.contentView) + #expect(abs(detail.contentView.documentVisibleRect.maxX - document.bounds.maxX) < 1) + detail.contentView.scroll(to: .zero) + detail.reflectScrolledClipView(detail.contentView) + } + } + + private static func settle(_ host: NSView, until predicate: () -> Bool) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + repeat { + host.layoutSubtreeIfNeeded() + if predicate() { return } + await Task.yield() + } while clock.now < deadline + Issue.record("Marketplace split did not finish the native drag within two seconds") + } + + private static func descendants(_ view: NSView) -> [NSView] { + view.subviews.flatMap { [$0] + descendants($0) } + } +} + +@MainActor +private final class MarketplaceLayoutService: PluginMarketplaceServicing { + let platform = "darwin-arm64" + private let source = PluginMarketplaceSource(id: "layout-source", name: "Lithe Store", + input: "https://github.com/example/plugins", indexURL: "https://example.invalid/index.json", + repository: "https://github.com/example/plugins", isEnabled: true) + private var index: PluginMarketplaceIndex { + .init(plugins: [.init(id: "io.example.cargo", name: "Cargo", author: "Lithe Community", + repository: "https://github.com/example/lithe-plugin-cargo", homepage: "https://example.invalid/cargo", + description: "Cargo build integration with workspace and package tasks, release profiles, features and offline builds.", + localizedDescriptions: ["zh-Hans": "Cargo 构建预发布插件:原生 workspace/包编译、检查、测试、清理及 binary/example 运行,支持 Release、Features 和 Offline。较新的版本需要支持对应能力的宿主。"], + tags: ["build", "cargo", "rust", "testing"], license: "Apache-2.0", icon: nil, + platforms: ["darwin-arm64", "darwin-x64"], latestVersion: "0.1.1", latestReleaseDate: "2026-10-22", + availability: .available(.init(version: "0.1.0", pluginAPIVersion: 3, releaseDate: "2026-10-21", + downloadURL: "https://example.invalid/plugin.zip", sha256: String(repeating: "a", count: 64))), + versions: [.init(version: "0.1.0", pluginAPIVersion: 3, releaseDate: "2026-10-21", platforms: ["darwin-arm64", "darwin-x64"])])], rejected: []) + } + func loadSources() -> [PluginMarketplaceSource] { + [source, .init(id: "other-layout-source", name: "Other Store", input: "https://github.com/example/other", + indexURL: "https://example.invalid/other.json", repository: "https://github.com/example/other", isEnabled: true)] + } + func saveSources(_ sources: [PluginMarketplaceSource]) throws {} + func resolveSource(_ input: String) throws -> PluginMarketplaceSourceResolution { throw PluginMarketplaceIconRejected() } + func cachedIndex(for source: PluginMarketplaceSource) -> PluginMarketplaceIndexSnapshot? { .init(index: index, fetchedAt: nil) } + func refreshIndex(for source: PluginMarketplaceSource) async throws -> PluginMarketplaceIndexSnapshot { .init(index: index, fetchedAt: nil) } + func removeCache(for source: PluginMarketplaceSource) {} + func iconData(from icon: String) async throws -> Data { throw PluginMarketplaceIconRejected() } + func prepareInstall(entry: PluginMarketplaceEntry, download: PluginMarketplaceDownload, source: PluginMarketplaceSource, + approvedPrograms: [PluginProgram]?) async throws -> PreparedPluginPackage { throw PluginMarketplaceIconRejected() } + func discard(_ prepared: PreparedPluginPackage) {} + func prepareDiskInstall(at url: URL) async throws -> PreparedDiskPluginPackage { throw PluginMarketplaceIconRejected() } + func discard(_ prepared: PreparedDiskPluginPackage) {} +} + +@MainActor +private final class MarketplaceLayoutManager: PluginManaging { + let snapshots: [PluginManagementSnapshot] = [] + let issues: [PluginManagementIssue] = [] + func setEnabled(_ enabled: Bool, for pluginID: PluginID) async throws {} + func setSetting(_ value: ToolingJSONValue?, id settingID: String, for pluginID: PluginID) throws {} + func installMarketplacePackage(at packageURL: URL, origin: PluginInstallationOrigin, provenance: PluginMarketplaceProvenance) throws {} + func installDiskPackage(at packageURL: URL, provenance: PluginDiskProvenance) throws {} + func installPackage(at packageURL: URL) throws {} + func rollback(_ pluginID: PluginID) throws {} + func uninstall(_ pluginID: PluginID) async throws {} + func restore(_ pluginID: PluginID) throws {} +} diff --git a/shared/platform-feature-matrix/features/plugin-marketplace.json b/shared/platform-feature-matrix/features/plugin-marketplace.json index 5c65523f6c..a355a15d87 100644 --- a/shared/platform-feature-matrix/features/plugin-marketplace.json +++ b/shared/platform-feature-matrix/features/plugin-marketplace.json @@ -36,7 +36,8 @@ ".github/workflows/release-official-plugins.yml", "macos/Sources/Lithe/Views/App/ApplicationRestartAction.swift", "macos/Sources/Lithe/Platform/MacOS/MacApplicationRelauncher.swift", - "macos/Tests/LitheTests/MacApplicationRelauncherTests.swift" + "macos/Tests/LitheTests/MacApplicationRelauncherTests.swift", + "macos/Tests/LitheTests/PluginMarketplaceLayoutTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -52,5 +53,5 @@ }, "owner": "Extensions", "notes": "macOS 已在隔离实例实际验证社区 ZIP/目录选择与确认、本地来源和摘要、完整程序 JSON 展开、同版本再次确认及取消不改原记录、安装字节、重启扫描与 bundle 哈希不变;磁盘信任及受影响安装流程已运行定向回归,后台审查通过工作线程独占 Store 与文件管理器保持界面可取消,取消后等待读取结束再清理快照。页面关闭与迟到结果清理由状态回归覆盖,完整市场源管理、双语和全部磁盘组合尚未逐项实机验收,因此整体 verificationStatus 保持 pending。最终 Maven/Gradle 新清单还依赖待合入的共享契约和宿主能力,本次 preview 基线的受控包验收不替代最终插件整体验收。Windows 未实现本次声明式磁盘安装链,partial 不表示可以运行 macOS 流程。", - "verification": "macOS 打开插件管理,确认有“插件市场 / 已安装”两个标签和齿轮菜单;默认有“Lithe 官方”源(不能删除)和预置的社区源 Lithe Store(默认开启、可停用和删除,删除后重启不会再出现),Lithe Store 中能看到 Lua、Go、PHP 三个社区语言插件并可安装,“Lithe 官方”源当前没有可下载插件(Go/PHP 已迁到 Lithe Store),刷新后索引缓存写在 Caches 而 app bundle 不变。安装、更新或卸载插件后,插件管理顶部出现“插件变更将在重启 Lithe 后生效”横幅和“重启 Lithe”按钮,市场列表与详情里的“重启后生效”也变成该按钮;点击后走正常退出确认,确认后应用退出并自动重新打开(不会多开),取消任一确认则不重启。插件详情显示作者、来源仓库、标签、版本、许可证、发布日期和支持平台;索引带 SVG 或 PNG `icon` 的插件(如 Lithe Store 的 Go、PHP、Lua、Maven、Gradle)在列表、详情和“已安装”里显示自己的图标;图标缺失、非法、超限或仍在加载时显示 IDEA 风格首字母头像(英文取两个字母、中文取一个字,颜色按插件 ID 固定),只有未选择插件的空状态显示默认插件图标;断网导致的失败在网络恢复后再次打开页面或刷新仓库即可恢复,刷新仓库后分支 URL 的新图标会替换旧图标,快速切换插件时详情头部不会出现上一个插件的图标;图标不写 bundle;索引里没有当前架构的包时显示“暂不支持 <平台> 平台”且不可安装,插件 API 版本不满足时显示不支持原因。在“管理插件仓库”中添加一个 GitHub 仓库地址,确认解析到仓库根目录的 lithe-plugin-index.json,可刷新、停用和删除;弹窗按设计稿呈现:仓库列在圆角卡片里,每行是名称(旁边有标签:“Lithe 官方”和预置的 Lithe Store 为“官方”,用户添加的仓库为“社区”)、等宽索引地址和状态行(“已于 刚刚 刷新”随时间变为“5 分钟前”,超过一周显示日期;有被忽略条目时追加“已忽略条目:N”,停用的源标注“已禁用”,刷新失败显示原因),右侧依次是启用开关、“刷新”和红色“移除”;“Lithe 官方”行只能停用,用灰色“默认”代替“移除”;底部左侧“全部刷新”,右侧“完成”。第三方源列出 dev.lithe.* 插件时该条目被忽略。安装社区插件时先校验 SHA-256(不匹配则不解压),超过下载上限的响应在下载中途被中止,解压后过大或文件过多的压缩包(含谎报大小的 ZIP)被拒绝且不留临时文件,再弹出确认框列出权限、要运行的程序和来源仓库,取消则不安装;已安装后改动插件目录内任意文件,重启后该插件拒绝加载;本地磁盘社区首次安装、更新、重装和同版本替换每次都必须明确确认,即使权限和程序未变;市场入口保持首次/缺少审批/出现 newPrograms 时确认的既有规则,无新程序的市场更新可沿用审批。确认框展示 Rust installReview 的全部权限与完整 canonical program 内容,包括参数、参数模板、验证探测、初始化选项、运行时与构建声明,已识别程序展开 Full Program Declaration 阅读完整 JSON,未知程序直接显示完整 JSON;同一插件出现在多个源时由用户选源,更新只走安装时记录的源。官方源只接受 Lithe 签名包。分别在英文和简体中文界面下检查列表、详情、安装确认框和仓库管理窗口,文字(含刷新时间格式)都跟随 Lithe 的语言设置,插件描述在有 localizedDescriptions 翻译时显示对应语言,否则显示默认英文描述;用辅助功能“按下”列表行只会选中,不会触发安装。索引里最新版本在本机不可用时,详情的版本、发布日期和平台显示实际会安装的版本,并单独注明最新版本不可用。磁盘验收:安装声明内置 Python 或其他已安装插件语言的社区包,重启拒绝冲突包后,原拥有者未设置、显式关闭或开启的偏好均保持原值;正常首次确认安装仍自动启用,更新保留选择。分别在市场下载和磁盘导入入口构造显式 ZIP 条目未超限但唯一隐式父目录超限的包,以及累计规范化路径 UTF-8 字节超限的包,确认在启动解压前拒绝并清理;显式目录与共享隐式父目录只计一次,恰好预算的合法包可解压。在隔离的用户配置与注入临时根下,从齿轮菜单分别选择一个合法社区 ZIP 与解包目录;准备后检查只生成本次 lithe-plugin-disk- 私有快照,ZIP 的 archiveSHA256 与快照 archive.zip 实测 SHA-256 相同,目录无归档摘要,界面显示本地文件名/内容摘要并明确未认证发布者或仓库。先取消,确认未安装且本次目录删除;再确认安装并重启,核对 installation.json 仅有 disk 来源、内容 digest 与完整 canonical approvedPrograms,没有伪造 marketplace 来源。分别导入程序未变的新版、相同版本原包及相同版本改过脚本的包,每次必须完整展示权限和程序并等待确认;取消不得改变已安装版本。确认框等待时更换原始 ZIP/目录,安装内容应仍等于准备快照;在受控测试中改动已准备快照后提交,应在 install staging 重验失败;安装后增加/删除/改动文件应在 scan 拒绝加载,改动上一版本后 rollback 应失败。正常升级再回滚时,当前/上一版本各自保留 origin、disk/marketplace 来源;同版本替换保留既有上一版本。刷新包含同 ID 的市场源,确认磁盘版本不出现可自动接管的更新/重装入口。以符号链接、Finder alias、特殊文件、多个 plugin.json、超过 64 层的目录、落在源目录内的临时根、中央目录/local header 路径类型冲突或重复条目的 ZIP、超限或损坏 ZIP、非法声明、冒用官方 ID/namespace 的包验证拒绝与清理;官方 publisher 签名、sameTeamAsHost 的 bundle 边界及仅 DEBUG 可用的 localDevelopment 包仍按原策略。导入和待确认期间关闭插件页,确认任务取消、已有快照立即清理、迟到的准备结果只清理而不会安装;成功、取消和失败后检查本次快照和 staging 无残留;在典型工作流前后比较发行 bundle 文件清单/哈希相同,关闭本次应用与子进程。静态及资源验证命令:node scripts/test-reuse-worktree-resources.mjs、./scripts/verify-agent-notes.sh、./scripts/verify-platform-feature-matrix.sh、./scripts/verify-runtime-bundle-immutability.sh;macOS 产品测试使用 ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginMarketplace|PluginDiskInstallation|MacPluginDiskPackageImporter|PluginPackageStore|PluginManager',核对报告包含实际磁盘用例,未运行产品验收前保持 pending。Windows 当前仅共享索引契约、fixture 和 Rust Core 校验,无插件市场 UI 或此声明式 ZIP/目录快照审批流程,现有 worker-package 导入属于另一格式,实现状态保持 partial。" + "verification": "macOS 打开插件管理,确认有“插件市场 / 已安装”两个标签和齿轮菜单;默认有“Lithe 官方”源(不能删除)和预置的社区源 Lithe Store(默认开启、可停用和删除,删除后重启不会再出现),Lithe Store 中能看到 Lua、Go、PHP 三个社区语言插件并可安装,“Lithe 官方”源当前没有可下载插件(Go/PHP 已迁到 Lithe Store),刷新后索引缓存写在 Caches 而 app bundle 不变。安装、更新或卸载插件后,插件管理顶部出现“插件变更将在重启 Lithe 后生效”横幅和“重启 Lithe”按钮,市场列表与详情里的“重启后生效”也变成该按钮;点击后走正常退出确认,确认后应用退出并自动重新打开(不会多开),取消任一确认则不重启。插件详情显示作者、来源仓库、标签、版本、许可证、发布日期和支持平台;索引带 SVG 或 PNG `icon` 的插件(如 Lithe Store 的 Go、PHP、Lua、Maven、Gradle)在列表、详情和“已安装”里显示自己的图标;图标缺失、非法、超限或仍在加载时显示 IDEA 风格首字母头像(英文取两个字母、中文取一个字,颜色按插件 ID 固定),只有未选择插件的空状态显示默认插件图标;断网导致的失败在网络恢复后再次打开页面或刷新仓库即可恢复,刷新仓库后分支 URL 的新图标会替换旧图标,快速切换插件时详情头部不会出现上一个插件的图标;图标不写 bundle;索引里没有当前架构的包时显示“暂不支持 <平台> 平台”且不可安装,插件 API 版本不满足时显示不支持原因。在“管理插件仓库”中添加一个 GitHub 仓库地址,确认解析到仓库根目录的 lithe-plugin-index.json,可刷新、停用和删除;弹窗按设计稿呈现:仓库列在圆角卡片里,每行是名称(旁边有标签:“Lithe 官方”和预置的 Lithe Store 为“官方”,用户添加的仓库为“社区”)、等宽索引地址和状态行(“已于 刚刚 刷新”随时间变为“5 分钟前”,超过一周显示日期;有被忽略条目时追加“已忽略条目:N”,停用的源标注“已禁用”,刷新失败显示原因),右侧依次是启用开关、“刷新”和红色“移除”;“Lithe 官方”行只能停用,用灰色“默认”代替“移除”;底部左侧“全部刷新”,右侧“完成”。第三方源列出 dev.lithe.* 插件时该条目被忽略。安装社区插件时先校验 SHA-256(不匹配则不解压),超过下载上限的响应在下载中途被中止,解压后过大或文件过多的压缩包(含谎报大小的 ZIP)被拒绝且不留临时文件,再弹出确认框列出权限、要运行的程序和来源仓库,取消则不安装;已安装后改动插件目录内任意文件,重启后该插件拒绝加载;本地磁盘社区首次安装、更新、重装和同版本替换每次都必须明确确认,即使权限和程序未变;市场入口保持首次/缺少审批/出现 newPrograms 时确认的既有规则,无新程序的市场更新可沿用审批。确认框展示 Rust installReview 的全部权限与完整 canonical program 内容,包括参数、参数模板、验证探测、初始化选项、运行时与构建声明,已识别程序展开 Full Program Declaration 阅读完整 JSON,未知程序直接显示完整 JSON;同一插件出现在多个源时由用户选源,更新只走安装时记录的源。官方源只接受 Lithe 签名包。分别在英文和简体中文界面下检查列表、详情、安装确认框和仓库管理窗口,文字(含刷新时间格式)都跟随 Lithe 的语言设置,插件描述在有 localizedDescriptions 翻译时显示对应语言,否则显示默认英文描述;用辅助功能“按下”列表行只会选中,不会触发安装。索引里最新版本在本机不可用时,详情的版本、发布日期和平台显示实际会安装的版本,并单独注明最新版本不可用。磁盘验收:安装声明内置 Python 或其他已安装插件语言的社区包,重启拒绝冲突包后,原拥有者未设置、显式关闭或开启的偏好均保持原值;正常首次确认安装仍自动启用,更新保留选择。分别在市场下载和磁盘导入入口构造显式 ZIP 条目未超限但唯一隐式父目录超限的包,以及累计规范化路径 UTF-8 字节超限的包,确认在启动解压前拒绝并清理;显式目录与共享隐式父目录只计一次,恰好预算的合法包可解压。在隔离的用户配置与注入临时根下,从齿轮菜单分别选择一个合法社区 ZIP 与解包目录;准备后检查只生成本次 lithe-plugin-disk- 私有快照,ZIP 的 archiveSHA256 与快照 archive.zip 实测 SHA-256 相同,目录无归档摘要,界面显示本地文件名/内容摘要并明确未认证发布者或仓库。先取消,确认未安装且本次目录删除;再确认安装并重启,核对 installation.json 仅有 disk 来源、内容 digest 与完整 canonical approvedPrograms,没有伪造 marketplace 来源。分别导入程序未变的新版、相同版本原包及相同版本改过脚本的包,每次必须完整展示权限和程序并等待确认;取消不得改变已安装版本。确认框等待时更换原始 ZIP/目录,安装内容应仍等于准备快照;在受控测试中改动已准备快照后提交,应在 install staging 重验失败;安装后增加/删除/改动文件应在 scan 拒绝加载,改动上一版本后 rollback 应失败。正常升级再回滚时,当前/上一版本各自保留 origin、disk/marketplace 来源;同版本替换保留既有上一版本。刷新包含同 ID 的市场源,确认磁盘版本不出现可自动接管的更新/重装入口。以符号链接、Finder alias、特殊文件、多个 plugin.json、超过 64 层的目录、落在源目录内的临时根、中央目录/local header 路径类型冲突或重复条目的 ZIP、超限或损坏 ZIP、非法声明、冒用官方 ID/namespace 的包验证拒绝与清理;官方 publisher 签名、sameTeamAsHost 的 bundle 边界及仅 DEBUG 可用的 localDevelopment 包仍按原策略。导入和待确认期间关闭插件页,确认任务取消、已有快照立即清理、迟到的准备结果只清理而不会安装;成功、取消和失败后检查本次快照和 staging 无残留;在典型工作流前后比较发行 bundle 文件清单/哈希相同,关闭本次应用与子进程。静态及资源验证命令:node scripts/test-reuse-worktree-resources.mjs、./scripts/verify-agent-notes.sh、./scripts/verify-platform-feature-matrix.sh、./scripts/verify-runtime-bundle-immutability.sh;macOS 产品测试使用 ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginMarketplace|PluginDiskInstallation|MacPluginDiskPackageImporter|PluginPackageStore|PluginManager',核对报告包含实际磁盘用例,未运行产品验收前保持 pending。Windows 当前仅共享索引契约、fixture 和 Rust Core 校验,无插件市场 UI 或此声明式 ZIP/目录快照审批流程,现有 worker-package 导入属于另一格式,实现状态保持 partial。;市场列表与详情之间的分隔条使用真实页面回归:中英文描述与深浅主题下缩放窗口、拖动后释放,再刷新索引,详情视口始终位于分隔条右侧;正文按视口宽度换行,窄于来源选择器和提示卡所需宽度时可横向滚动至末端,短详情保持顶部对齐。测试使用私有临时偏好域,关闭窗口后清理;仅原生组件证据,整体实机验收仍 pending。" }