From c7fa4c6fdafe81b3c1043a71a34a680774693e73 Mon Sep 17 00:00:00 2001 From: Htx <17922172+Houtx@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:38:08 +0800 Subject: [PATCH 1/7] Use portable tools in local appcast script --- scripts/create-appcast.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/create-appcast.sh b/scripts/create-appcast.sh index 1bcde73..6408e21 100755 --- a/scripts/create-appcast.sh +++ b/scripts/create-appcast.sh @@ -16,7 +16,7 @@ release_notes=${1:-"$project_root/release-notes/v$version.md"} sparkle_account="com.inputstatus.desktop" download_prefix="https://github.com/Houtx/Project-Input-Status/releases/download/v$version/" -for required_tool in ditto plutil rg xmllint; do +for required_tool in ditto grep plutil xmllint; do if ! command -v "$required_tool" >/dev/null 2>&1; then print -u2 "Missing required tool: $required_tool" exit 1 @@ -61,11 +61,11 @@ fi "$appcast_staging" xmllint --noout "$appcast_file" -if ! rg -q 'sparkle:edSignature=' "$appcast_file"; then +if ! grep -q 'sparkle:edSignature=' "$appcast_file"; then print -u2 "Generated appcast does not contain an Ed25519 signature." exit 1 fi -if ! rg -q "$download_prefix$dmg_name" "$appcast_file"; then +if ! grep -Fq "$download_prefix$dmg_name" "$appcast_file"; then print -u2 "Generated appcast does not reference the expected GitHub release asset." exit 1 fi From 8fe4171e18d13b7943cb0d86c3fad5d071f1717b Mon Sep 17 00:00:00 2001 From: Htx <17922172+Houtx@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:02:14 +0800 Subject: [PATCH 2/7] Harden status handling and build pipeline --- .github/workflows/ci.yml | 30 +++++ .github/workflows/publish-appcast.yml | 6 + InputStatus.xcodeproj/project.pbxproj | 13 +- InputStatus/App/InputStatusApp.swift | 58 +++++++- InputStatus/Resources/InputStatus-Info.plist | 2 - .../Resources/InputStatusWidget-Info.plist | 2 - InputStatus/Shared/StatusAPI.swift | 27 +++- InputStatus/Shared/StatusModels.swift | 104 +++++++++++++- README.md | 8 ++ Tests/StatusCoreTests.swift | 127 ++++++++++++++++++ project.yml | 3 + scripts/build.sh | 23 ++-- scripts/install.sh | 14 +- scripts/test.sh | 51 +++++++ 14 files changed, 430 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 Tests/StatusCoreTests.swift create mode 100755 scripts/test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a2fb441 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: Test and build without Xcode project + runs-on: macos-14 + timeout-minutes: 20 + steps: + - name: Check out repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Run core tests + shell: /bin/zsh {0} + run: ./scripts/test.sh + + - name: Build Universal 2 app + shell: /bin/zsh {0} + run: ./scripts/build.sh diff --git a/.github/workflows/publish-appcast.yml b/.github/workflows/publish-appcast.yml index 9e6008e..19cdad1 100644 --- a/.github/workflows/publish-appcast.yml +++ b/.github/workflows/publish-appcast.yml @@ -57,6 +57,9 @@ jobs: dmg_file=${dmg_files[1]} curl --fail --location --show-error \ + --retry 3 \ + --retry-all-errors \ + --retry-delay 2 \ --output "$work_root/$sparkle_archive" \ "https://github.com/sparkle-project/Sparkle/releases/download/$sparkle_version/$sparkle_archive" actual_sha256=$(shasum -a 256 "$work_root/$sparkle_archive" | awk '{print $1}') @@ -85,6 +88,9 @@ jobs: xmllint --noout "$appcast_file" grep -q 'sparkle:edSignature=' "$appcast_file" + grep -Fq \ + "https://github.com/$REPOSITORY/releases/download/$TAG_NAME/${dmg_file:t}" \ + "$appcast_file" gh release upload "$TAG_NAME" "$appcast_file" \ --repo "$REPOSITORY" \ --clobber diff --git a/InputStatus.xcodeproj/project.pbxproj b/InputStatus.xcodeproj/project.pbxproj index 6a343ae..90efe67 100644 --- a/InputStatus.xcodeproj/project.pbxproj +++ b/InputStatus.xcodeproj/project.pbxproj @@ -15,6 +15,8 @@ A10000000000000000000007 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000007 /* Assets.xcassets */; }; A10000000000000000000008 /* InputStatusWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A20000000000000000000008 /* InputStatusWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; A10000000000000000000009 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = AB0000000000000000000001 /* Sparkle */; }; + A10000000000000000000010 /* AppIcon.icns in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000015 /* AppIcon.icns */; }; + A10000000000000000000011 /* AppIcon.icns in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000015 /* AppIcon.icns */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -55,6 +57,7 @@ A20000000000000000000012 /* InputStatus.entitlements */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.entitlements; path = InputStatus.entitlements; sourceTree = ""; }; A20000000000000000000013 /* InputStatusWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.entitlements; path = InputStatusWidget.entitlements; sourceTree = ""; }; A20000000000000000000014 /* project.yml */ = {isa = PBXFileReference; lastKnownFileType = text; path = project.yml; sourceTree = ""; }; + A20000000000000000000015 /* AppIcon.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = AppIcon.icns; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -76,7 +79,7 @@ A80000000000000000000002 /* App */ = {isa = PBXGroup; children = (A20000000000000000000001 /* AppModel.swift */, A20000000000000000000002 /* InputStatusApp.swift */); path = App; sourceTree = ""; }; A80000000000000000000003 /* Shared */ = {isa = PBXGroup; children = (A20000000000000000000003 /* StatusAPI.swift */, A20000000000000000000004 /* StatusModels.swift */); path = Shared; sourceTree = ""; }; A80000000000000000000004 /* Widget */ = {isa = PBXGroup; children = (A20000000000000000000006 /* InputStatusWidget.swift */); path = Widget; sourceTree = ""; }; - A80000000000000000000005 /* Resources */ = {isa = PBXGroup; children = (A20000000000000000000007 /* Assets.xcassets */, A20000000000000000000010 /* InputStatus-Info.plist */, A20000000000000000000011 /* InputStatusWidget-Info.plist */, A20000000000000000000012 /* InputStatus.entitlements */, A20000000000000000000013 /* InputStatusWidget.entitlements */); path = Resources; sourceTree = ""; }; + A80000000000000000000005 /* Resources */ = {isa = PBXGroup; children = (A20000000000000000000007 /* Assets.xcassets */, A20000000000000000000015 /* AppIcon.icns */, A20000000000000000000010 /* InputStatus-Info.plist */, A20000000000000000000011 /* InputStatusWidget-Info.plist */, A20000000000000000000012 /* InputStatus.entitlements */, A20000000000000000000013 /* InputStatusWidget.entitlements */); path = Resources; sourceTree = ""; }; A80000000000000000000006 /* Products */ = {isa = PBXGroup; children = (A20000000000000000000009 /* InputStatus.app */, A20000000000000000000008 /* InputStatusWidgetExtension.appex */); name = Products; sourceTree = ""; }; /* End PBXGroup section */ @@ -125,8 +128,8 @@ /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - A60000000000000000000003 /* Resources */ = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (A10000000000000000000007 /* Assets.xcassets in Resources */); runOnlyForDeploymentPostprocessing = 0; }; - A60000000000000000000005 /* Resources */ = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }; + A60000000000000000000003 /* Resources */ = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (A10000000000000000000007 /* Assets.xcassets in Resources */, A10000000000000000000010 /* AppIcon.icns in Resources */); runOnlyForDeploymentPostprocessing = 0; }; + A60000000000000000000005 /* Resources */ = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (A10000000000000000000011 /* AppIcon.icns in Resources */); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -141,8 +144,8 @@ /* Begin XCBuildConfiguration section */ AA0000000000000000000001 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = {ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = ""; MACOSX_DEPLOYMENT_TARGET = 14.0; SDKROOT = macosx; SWIFT_VERSION = 5.0; }; name = Debug; }; AA0000000000000000000002 /* Release */ = {isa = XCBuildConfiguration; buildSettings = {ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = ""; MACOSX_DEPLOYMENT_TARGET = 14.0; SDKROOT = macosx; SWIFT_VERSION = 5.0; }; name = Release; }; - AA0000000000000000000003 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = {ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = InputStatus/Resources/InputStatus.entitlements; INFOPLIST_FILE = InputStatus/Resources/InputStatus-Info.plist; LD_RUNPATH_SEARCH_PATHS = ("$(inherited)", "@executable_path/../Frameworks"); PRODUCT_BUNDLE_IDENTIFIER = com.inputstatus.desktop; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Debug; }; - AA0000000000000000000004 /* Release */ = {isa = XCBuildConfiguration; buildSettings = {ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = InputStatus/Resources/InputStatus.entitlements; INFOPLIST_FILE = InputStatus/Resources/InputStatus-Info.plist; LD_RUNPATH_SEARCH_PATHS = ("$(inherited)", "@executable_path/../Frameworks"); PRODUCT_BUNDLE_IDENTIFIER = com.inputstatus.desktop; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Release; }; + AA0000000000000000000003 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = {CODE_SIGN_ENTITLEMENTS = InputStatus/Resources/InputStatus.entitlements; INFOPLIST_FILE = InputStatus/Resources/InputStatus-Info.plist; LD_RUNPATH_SEARCH_PATHS = ("$(inherited)", "@executable_path/../Frameworks"); PRODUCT_BUNDLE_IDENTIFIER = com.inputstatus.desktop; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Debug; }; + AA0000000000000000000004 /* Release */ = {isa = XCBuildConfiguration; buildSettings = {CODE_SIGN_ENTITLEMENTS = InputStatus/Resources/InputStatus.entitlements; INFOPLIST_FILE = InputStatus/Resources/InputStatus-Info.plist; LD_RUNPATH_SEARCH_PATHS = ("$(inherited)", "@executable_path/../Frameworks"); PRODUCT_BUNDLE_IDENTIFIER = com.inputstatus.desktop; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Release; }; AA0000000000000000000005 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = {APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = InputStatus/Resources/InputStatusWidget.entitlements; INFOPLIST_FILE = InputStatus/Resources/InputStatusWidget-Info.plist; LD_RUNPATH_SEARCH_PATHS = ("$(inherited)", "@executable_path/../Frameworks"); PRODUCT_BUNDLE_IDENTIFIER = com.inputstatus.desktop.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; }; name = Debug; }; AA0000000000000000000006 /* Release */ = {isa = XCBuildConfiguration; buildSettings = {APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = InputStatus/Resources/InputStatusWidget.entitlements; INFOPLIST_FILE = InputStatus/Resources/InputStatusWidget-Info.plist; LD_RUNPATH_SEARCH_PATHS = ("$(inherited)", "@executable_path/../Frameworks"); PRODUCT_BUNDLE_IDENTIFIER = com.inputstatus.desktop.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; }; name = Release; }; /* End XCBuildConfiguration section */ diff --git a/InputStatus/App/InputStatusApp.swift b/InputStatus/App/InputStatusApp.swift index 22fffb1..b8e2e91 100644 --- a/InputStatus/App/InputStatusApp.swift +++ b/InputStatus/App/InputStatusApp.swift @@ -13,6 +13,7 @@ final class InputStatusAppDelegate: NSObject, NSApplicationDelegate, @MainActor userDriverDelegate: self ) private var desktopWidgetController: DesktopWidgetWindowController? + private var updaterIsPresenting = false var isDesktopWidgetVisible: Bool { desktopWidgetController?.isVisible == true @@ -36,20 +37,20 @@ final class InputStatusAppDelegate: NSObject, NSApplicationDelegate, @MainActor desktopWidgetController?.toggle() } - var supportsGentleScheduledUpdateReminders: Bool { - true - } - func standardUserDriverWillHandleShowingUpdate( _ handleShowingUpdate: Bool, forUpdate update: SUAppcastItem, state: SPUUserUpdateState ) { + guard handleShowingUpdate else { return } + updaterIsPresenting = true NSApp.setActivationPolicy(.regular) NSApp.activate() } func standardUserDriverWillFinishUpdateSession() { + guard updaterIsPresenting else { return } + updaterIsPresenting = false NSApp.setActivationPolicy(.accessory) } @@ -241,6 +242,7 @@ private final class DesktopWidgetWindowController: NSObject { private let settings = DesktopWidgetSettings() private var window: NSPanel? private var hostingController: NSHostingController? + private var screenParametersObserver: NSObjectProtocol? init(model: AppModel) { self.model = model @@ -248,6 +250,21 @@ private final class DesktopWidgetWindowController: NSObject { settings.onLockChanged = { [weak self] isLocked in self?.applyLockState(isLocked) } + screenParametersObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.ensureWindowIsVisible() + } + } + } + + deinit { + if let screenParametersObserver { + NotificationCenter.default.removeObserver(screenParametersObserver) + } } var isVisible: Bool { @@ -258,6 +275,7 @@ private final class DesktopWidgetWindowController: NSObject { if window == nil { makeWindow() } + ensureWindowIsVisible() window?.orderFrontRegardless() } @@ -287,6 +305,7 @@ private final class DesktopWidgetWindowController: NSObject { panel.backgroundColor = .clear panel.hasShadow = true panel.hidesOnDeactivate = false + panel.isMovable = !settings.isLocked panel.isMovableByWindowBackground = !settings.isLocked let desktopWidgetLevel = Int(CGWindowLevelForKey(.desktopIconWindow)) + 1 panel.level = NSWindow.Level(rawValue: desktopWidgetLevel) @@ -306,11 +325,34 @@ private final class DesktopWidgetWindowController: NSObject { } window = panel + ensureWindowIsVisible() } private func applyLockState(_ isLocked: Bool) { + window?.isMovable = !isLocked window?.isMovableByWindowBackground = !isLocked } + + private func ensureWindowIsVisible() { + guard let window else { return } + let screens = NSScreen.screens + guard let screen = screens.first(where: { $0.visibleFrame.intersects(window.frame) }) + ?? NSScreen.main + ?? screens.first else { + return + } + + let visibleFrame = screen.visibleFrame + let maximumX = max(visibleFrame.minX, visibleFrame.maxX - window.frame.width) + let maximumY = max(visibleFrame.minY, visibleFrame.maxY - window.frame.height) + let origin = NSPoint( + x: min(max(window.frame.minX, visibleFrame.minX), maximumX), + y: min(max(window.frame.minY, visibleFrame.minY), maximumY) + ) + if origin != window.frame.origin { + window.setFrameOrigin(origin) + } + } } @MainActor @@ -497,6 +539,7 @@ private struct DesktopWidgetView: View { private struct DesktopGlassModifier: ViewModifier { @ViewBuilder func body(content: Content) -> some View { +#if compiler(>=6.2) if #available(macOS 26.0, *) { content .glassEffect(.regular, in: .rect(cornerRadius: 18)) @@ -507,5 +550,12 @@ private struct DesktopGlassModifier: ViewModifier { in: RoundedRectangle(cornerRadius: 18, style: .continuous) ) } +#else + content + .background( + .ultraThinMaterial, + in: RoundedRectangle(cornerRadius: 18, style: .continuous) + ) +#endif } } diff --git a/InputStatus/Resources/InputStatus-Info.plist b/InputStatus/Resources/InputStatus-Info.plist index 175ffb1..c78c246 100644 --- a/InputStatus/Resources/InputStatus-Info.plist +++ b/InputStatus/Resources/InputStatus-Info.plist @@ -16,8 +16,6 @@ AppIcon - CFBundleIconName - AppIcon CFBundleInfoDictionaryVersion 6.0 CFBundleName diff --git a/InputStatus/Resources/InputStatusWidget-Info.plist b/InputStatus/Resources/InputStatusWidget-Info.plist index 4f0bbd7..3443983 100644 --- a/InputStatus/Resources/InputStatusWidget-Info.plist +++ b/InputStatus/Resources/InputStatusWidget-Info.plist @@ -16,8 +16,6 @@ AppIcon - CFBundleIconName - AppIcon CFBundleInfoDictionaryVersion 6.0 CFBundleName diff --git a/InputStatus/Shared/StatusAPI.swift b/InputStatus/Shared/StatusAPI.swift index 590dc5d..c10ee2b 100644 --- a/InputStatus/Shared/StatusAPI.swift +++ b/InputStatus/Shared/StatusAPI.swift @@ -3,6 +3,7 @@ import Foundation enum StatusAPIError: LocalizedError, Sendable { case badResponse(Int) case malformedResponse + case responseTooLarge var errorDescription: String? { switch self { @@ -10,6 +11,8 @@ enum StatusAPIError: LocalizedError, Sendable { return "服务器返回 HTTP \(code)" case .malformedResponse: return "状态数据格式无法识别" + case .responseTooLarge: + return "状态数据超过预期大小" } } } @@ -30,9 +33,13 @@ struct StatusAPI: Sendable { guard (200..<300).contains(httpResponse.statusCode) else { throw StatusAPIError.badResponse(httpResponse.statusCode) } + guard data.count <= InputStatusConstants.maximumResponseBytes else { + throw StatusAPIError.responseTooLarge + } do { - return try JSONDecoder().decode(StatusSnapshot.self, from: data) + let snapshot = try JSONDecoder().decode(StatusSnapshot.self, from: data) + return try snapshot.validated() } catch { throw StatusAPIError.malformedResponse } @@ -44,7 +51,18 @@ enum StatusStore { static func load() -> CachedStatus? { guard let data = UserDefaults.standard.data(forKey: key) else { return nil } - return try? JSONDecoder().decode(CachedStatus.self, from: data) + guard var cachedStatus = try? JSONDecoder().decode(CachedStatus.self, from: data) else { + UserDefaults.standard.removeObject(forKey: key) + return nil + } + if let snapshot = cachedStatus.snapshot { + guard let validatedSnapshot = try? snapshot.validated() else { + UserDefaults.standard.removeObject(forKey: key) + return nil + } + cachedStatus.snapshot = validatedSnapshot + } + return cachedStatus } static func save(_ cachedStatus: CachedStatus) throws { @@ -57,10 +75,11 @@ enum StatusRefresher { static func refresh() async throws -> CachedStatus { do { let snapshot = try await StatusAPI().fetch() + let now = Date() let result = CachedStatus( snapshot: snapshot, - fetchedAt: Date(), - lastAttemptAt: Date(), + fetchedAt: min(now, snapshot.generatedAt), + lastAttemptAt: now, lastError: nil ) try StatusStore.save(result) diff --git a/InputStatus/Shared/StatusModels.swift b/InputStatus/Shared/StatusModels.swift index 1a84014..70d79a6 100644 --- a/InputStatus/Shared/StatusModels.swift +++ b/InputStatus/Shared/StatusModels.swift @@ -6,9 +6,21 @@ enum InputStatusConstants { static let refreshURL = URL(string: "inputstatus://refresh")! static let widgetKind = "InputStatusWidget" static let historyLength = 60 + static let maximumServiceCount = 32 + static let maximumResponseBytes = 1_048_576 + static let maximumFutureClockSkew: TimeInterval = 300 + static let minimumGeneratedTimestamp: TimeInterval = 1_577_836_800 static let refreshInterval: TimeInterval = 120 } +enum StatusSnapshotValidationError: Error, Equatable { + case invalidGeneratedAt + case invalidServiceCount + case invalidService(String) + case duplicateService(String) + case inconsistentSummary +} + struct ProbeResult: Codable, Equatable, Sendable { let timestamp: Date let isOK: Bool @@ -37,6 +49,14 @@ struct ProbeResult: Codable, Equatable, Sendable { self.latencyMS = latencyMS self.error = error } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(timestamp.timeIntervalSince1970, forKey: .timestamp) + try container.encode(isOK, forKey: .isOK) + try container.encodeIfPresent(latencyMS, forKey: .latencyMS) + try container.encodeIfPresent(error, forKey: .error) + } } struct StatusService: Codable, Equatable, Sendable, Identifiable { @@ -102,6 +122,74 @@ struct StatusSnapshot: Codable, Equatable, Sendable { self.generatedAt = generatedAt self.services = services } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(allOK, forKey: .allOK) + try container.encode(generatedAt.timeIntervalSince1970, forKey: .generatedAt) + try container.encode(services, forKey: .services) + } + + func validated(referenceDate: Date = Date()) throws -> StatusSnapshot { + let generatedTimestamp = generatedAt.timeIntervalSince1970 + guard generatedTimestamp.isFinite, + generatedTimestamp >= InputStatusConstants.minimumGeneratedTimestamp, + generatedAt <= referenceDate.addingTimeInterval( + InputStatusConstants.maximumFutureClockSkew + ) else { + throw StatusSnapshotValidationError.invalidGeneratedAt + } + guard !services.isEmpty, + services.count <= InputStatusConstants.maximumServiceCount else { + throw StatusSnapshotValidationError.invalidServiceCount + } + + var seenModels = Set() + let normalizedServices = try services.map { service in + let model = service.model.trimmingCharacters(in: .whitespacesAndNewlines) + guard !model.isEmpty, + service.uptimePercent.isFinite, + (0...100).contains(service.uptimePercent) else { + throw StatusSnapshotValidationError.invalidService(service.model) + } + + let modelKey = model.lowercased() + guard seenModels.insert(modelKey).inserted else { + throw StatusSnapshotValidationError.duplicateService(model) + } + + let probes = service.history + [service.last].compactMap { $0 } + guard probes.allSatisfy({ probe in + probe.timestamp.timeIntervalSince1970.isFinite + && probe.timestamp.timeIntervalSince1970 > 0 + && (probe.latencyMS.map { $0 >= 0 } ?? true) + }) else { + throw StatusSnapshotValidationError.invalidService(model) + } + + let history = Array( + service.history + .sorted { $0.timestamp < $1.timestamp } + .suffix(InputStatusConstants.historyLength) + ) + return StatusService( + model: model, + uptimePercent: service.uptimePercent, + last: service.last, + history: history + ) + } + + guard allOK == normalizedServices.allSatisfy(\.isOnline) else { + throw StatusSnapshotValidationError.inconsistentSummary + } + + return StatusSnapshot( + allOK: allOK, + generatedAt: generatedAt, + services: normalizedServices + ) + } } struct CachedStatus: Codable, Equatable, Sendable { @@ -122,13 +210,21 @@ struct CachedStatus: Codable, Equatable, Sendable { self.lastError = lastError } - var age: TimeInterval? { + func age(at date: Date) -> TimeInterval? { guard let fetchedAt else { return nil } - return max(0, Date().timeIntervalSince(fetchedAt)) + return max(0, date.timeIntervalSince(fetchedAt)) } - var isStale: Bool { - guard let age else { return true } + var age: TimeInterval? { + age(at: Date()) + } + + func isStale(at date: Date) -> Bool { + guard let age = age(at: date) else { return true } return age > InputStatusConstants.refreshInterval * 2 } + + var isStale: Bool { + isStale(at: Date()) + } } diff --git a/README.md b/README.md index f03c4a3..f3732a0 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,14 @@ cd Project-Input-Status 构建产物位于 `build/InputStatus.app`。 首次构建会下载固定版本的 Sparkle 2,校验 SHA-256 后缓存到 `build/dependencies/`。 +运行核心数据测试和脚本/配置检查: + +```zsh +./scripts/test.sh +``` + +测试和正式构建都只使用 Command Line Tools,不要求安装完整 Xcode。GitHub Pull Request 也会自动运行同样的测试并构建 Universal 2 应用。 + 生成 Universal 2 DMG 发行包: ```zsh diff --git a/Tests/StatusCoreTests.swift b/Tests/StatusCoreTests.swift new file mode 100644 index 0000000..3ab77d9 --- /dev/null +++ b/Tests/StatusCoreTests.swift @@ -0,0 +1,127 @@ +import Foundation + +private enum TestFailure: Error, CustomStringConvertible { + case expectation(String) + + var description: String { + switch self { + case .expectation(let message): + return message + } + } +} + +private func expect(_ condition: @autoclosure () -> Bool, _ message: String) throws { + guard condition() else { + throw TestFailure.expectation(message) + } +} + +private func expectValidationFailure( + _ message: String, + operation: () throws -> Void +) throws { + do { + try operation() + } catch is StatusSnapshotValidationError { + return + } + throw TestFailure.expectation(message) +} + +@main +private struct StatusCoreTests { + static func main() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let history = (0..<65).map { index in + ProbeResult( + timestamp: now.addingTimeInterval(Double(index - 65) * 60), + isOK: true, + latencyMS: 1_000 + index + ) + } + let service = StatusService( + model: " gpt-test ", + uptimePercent: 99.9, + last: history.last, + history: Array(history.reversed()) + ) + let snapshot = StatusSnapshot( + allOK: true, + generatedAt: now, + services: [service] + ) + + let normalized = try snapshot.validated(referenceDate: now) + try expect(normalized.services[0].model == "gpt-test", "Service names must be trimmed") + try expect( + normalized.services[0].history.count == InputStatusConstants.historyLength, + "History must be capped" + ) + try expect( + normalized.services[0].history.first?.latencyMS == 1_005, + "History must be sorted before retaining recent probes" + ) + + let cached = CachedStatus( + snapshot: normalized, + fetchedAt: now, + lastAttemptAt: now + ) + let encoded = try JSONEncoder().encode(cached) + let decoded = try JSONDecoder().decode(CachedStatus.self, from: encoded) + try expect(decoded == cached, "Cached timestamps must survive a JSON round trip") + + let duplicate = StatusSnapshot( + allOK: true, + generatedAt: now, + services: [ + service, + StatusService( + model: "Gpt-Test", + uptimePercent: 100, + last: history.last, + history: [] + ) + ] + ) + try expectValidationFailure("Duplicate models must be rejected") { + _ = try duplicate.validated(referenceDate: now) + } + + let empty = StatusSnapshot(allOK: true, generatedAt: now, services: []) + try expectValidationFailure("Empty service lists must be rejected") { + _ = try empty.validated(referenceDate: now) + } + + let inconsistent = StatusSnapshot( + allOK: false, + generatedAt: now, + services: [service] + ) + try expectValidationFailure("Summary state must agree with service state") { + _ = try inconsistent.validated(referenceDate: now) + } + + let legacyEpoch = StatusSnapshot( + allOK: true, + generatedAt: Date(timeIntervalSince1970: 800_000_000), + services: [service] + ) + try expectValidationFailure("Corrupted legacy cache dates must be rejected") { + _ = try legacyEpoch.validated(referenceDate: now) + } + + let freshCache = CachedStatus(snapshot: normalized, fetchedAt: now) + try expect( + !freshCache.isStale(at: now.addingTimeInterval(240)), + "Cache should remain fresh at the stale threshold" + ) + try expect( + freshCache.isStale(at: now.addingTimeInterval(241)), + "Cache should become stale after the threshold" + ) + + print("Status core tests passed") + } +} diff --git a/project.yml b/project.yml index 12cc222..b30920d 100644 --- a/project.yml +++ b/project.yml @@ -22,6 +22,7 @@ targets: - path: InputStatus/Shared resources: - path: InputStatus/Resources/Assets.xcassets + - path: InputStatus/Resources/AppIcon.icns entitlements: InputStatus/Resources/InputStatus.entitlements info: path: InputStatus/Resources/InputStatus-Info.plist @@ -39,6 +40,8 @@ targets: sources: - path: InputStatus/Widget - path: InputStatus/Shared + resources: + - path: InputStatus/Resources/AppIcon.icns entitlements: InputStatus/Resources/InputStatusWidget.entitlements info: path: InputStatus/Resources/InputStatusWidget-Info.plist diff --git a/scripts/build.sh b/scripts/build.sh index af54af9..b3854e8 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -12,13 +12,6 @@ extension_executable="$extension_bundle/Contents/MacOS/InputStatusWidgetExtensio icon_file="$project_root/InputStatus/Resources/AppIcon.icns" arch_build_root="$build_root/Architectures" module_cache_root="$build_root/ModuleCache" -sparkle_root=$("$script_dir/bootstrap-sparkle.sh") -sparkle_framework="$sparkle_root/Sparkle.framework" -sparkle_license="$sparkle_root/LICENSE" -sdk_path=$(xcrun --sdk macosx --show-sdk-path) -sdk_version=$(xcrun --sdk macosx --show-sdk-version) -sdk_build=$(xcrun --sdk macosx --show-sdk-build-version) -os_build=$(sw_vers -buildVersion) deployment_target="14.0" target_arches=(arm64 x86_64) @@ -29,6 +22,14 @@ for required_tool in swiftc codesign ditto lipo plutil xcrun; do fi done +sparkle_root=$("$script_dir/bootstrap-sparkle.sh") +sparkle_framework="$sparkle_root/Sparkle.framework" +sparkle_license="$sparkle_root/LICENSE" +sdk_path=$(xcrun --sdk macosx --show-sdk-path) +sdk_version=$(xcrun --sdk macosx --show-sdk-version) +sdk_build=$(xcrun --sdk macosx --show-sdk-build-version) +os_build=$(sw_vers -buildVersion) + if [[ ! -f "$icon_file" ]]; then "$script_dir/generate-icon.sh" fi @@ -41,8 +42,12 @@ if [[ "$arch_build_root" != "$project_root/build/Architectures" ]]; then print -u2 "Refusing to clean an unexpected architecture path: $arch_build_root" exit 1 fi +if [[ "$module_cache_root" != "$project_root/build/ModuleCache" ]]; then + print -u2 "Refusing to clean an unexpected module cache path: $module_cache_root" + exit 1 +fi -rm -rf "$app_bundle" "$arch_build_root" +rm -rf "$app_bundle" "$arch_build_root" "$module_cache_root" mkdir -p \ "$app_bundle/Contents/MacOS" \ "$app_bundle/Contents/Frameworks" \ @@ -156,4 +161,6 @@ codesign \ codesign --verify --deep --strict --verbose=2 "$app_bundle" +rm -rf "$arch_build_root" "$module_cache_root" + print "Built: $app_bundle" diff --git a/scripts/install.sh b/scripts/install.sh index ba6c9a8..0f8ff4f 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -45,18 +45,14 @@ codesign --verify --deep --strict --verbose=2 "$installed_bundle" "$launch_services" -f "$installed_bundle" pluginkit -a "$installed_extension" pluginkit -e use -i com.inputstatus.desktop.widget >/dev/null 2>&1 || true -mkdir -p "$HOME/Library/LaunchAgents" launchctl bootout "$launch_domain" "$launch_agent" >/dev/null 2>&1 || true -plutil -create xml1 "$launch_agent" -plutil -insert Label -string com.inputstatus.desktop "$launch_agent" -plutil -insert ProgramArguments -json \ - "[\"/usr/bin/open\",\"-g\",\"$installed_bundle\"]" \ - "$launch_agent" -plutil -insert RunAtLoad -bool true "$launch_agent" -launchctl bootstrap "$launch_domain" "$launch_agent" +if [[ -f "$launch_agent" ]]; then + rm -f "$launch_agent" +fi killall chronod >/dev/null 2>&1 || true killall NotificationCenter >/dev/null 2>&1 || true killall WidgetConfigurationExtension >/dev/null 2>&1 || true +/usr/bin/open -g "$installed_bundle" print "Installed: $installed_bundle" -print "The desktop widget starts automatically and will reopen after login." +print "The desktop widget is running and will reopen after login." diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..d80b4d7 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,51 @@ +#!/bin/zsh + +set -euo pipefail + +script_dir=${0:A:h} +project_root=${script_dir:h} +test_root="$project_root/build/Tests" +test_executable="$test_root/StatusCoreTests" + +for required_tool in plutil swiftc xcrun; do + if ! command -v "$required_tool" >/dev/null 2>&1; then + print -u2 "Missing required tool: $required_tool" + exit 1 + fi +done + +if [[ "$test_root" != "$project_root/build/Tests" ]]; then + print -u2 "Refusing to clean an unexpected test path: $test_root" + exit 1 +fi + +target_arch=$(uname -m) +if [[ "$target_arch" != "arm64" && "$target_arch" != "x86_64" ]]; then + print -u2 "Unsupported test architecture: $target_arch" + exit 1 +fi + +sdk_path=$(xcrun --sdk macosx --show-sdk-path) +rm -rf "$test_root" +mkdir -p "$test_root" + +swiftc \ + -warnings-as-errors \ + -swift-version 5 \ + -target "$target_arch-apple-macosx14.0" \ + -sdk "$sdk_path" \ + -o "$test_executable" \ + "$project_root/InputStatus/Shared/StatusModels.swift" \ + "$project_root/InputStatus/Shared/StatusAPI.swift" \ + "$project_root/Tests/StatusCoreTests.swift" + +"$test_executable" + +for script_file in "$project_root"/scripts/*.sh; do + zsh -n "$script_file" +done +plutil -lint \ + "$project_root"/InputStatus/Resources/*.plist \ + "$project_root"/InputStatus/Resources/*.entitlements >/dev/null + +print "Script and plist checks passed" From a23e834a9019ba7eb8d6a7b1ded505c40857af5d Mon Sep 17 00:00:00 2001 From: Htx <17922172+Houtx@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:09:00 +0800 Subject: [PATCH 3/7] Fix notification task captures on macOS 14 --- InputStatus/App/AppModel.swift | 2 +- InputStatus/App/InputStatusApp.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/InputStatus/App/AppModel.swift b/InputStatus/App/AppModel.swift index f3ee634..7b6407e 100644 --- a/InputStatus/App/AppModel.swift +++ b/InputStatus/App/AppModel.swift @@ -19,7 +19,7 @@ final class AppModel: ObservableObject { object: nil, queue: .main ) { [weak self] _ in - Task { @MainActor in self?.refreshNow() } + Task { @MainActor [weak self] in self?.refreshNow() } } startRefreshing() } diff --git a/InputStatus/App/InputStatusApp.swift b/InputStatus/App/InputStatusApp.swift index b8e2e91..9fe6f95 100644 --- a/InputStatus/App/InputStatusApp.swift +++ b/InputStatus/App/InputStatusApp.swift @@ -255,7 +255,7 @@ private final class DesktopWidgetWindowController: NSObject { object: nil, queue: .main ) { [weak self] _ in - Task { @MainActor in + Task { @MainActor [weak self] in self?.ensureWindowIsVisible() } } From 432498ba2e3646b016c247ce6a97af6352030dea Mon Sep 17 00:00:00 2001 From: Htx <17922172+Houtx@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:10:50 +0800 Subject: [PATCH 4/7] Bridge Sparkle callbacks to the main actor --- InputStatus/App/InputStatusApp.swift | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/InputStatus/App/InputStatusApp.swift b/InputStatus/App/InputStatusApp.swift index 9fe6f95..9b92928 100644 --- a/InputStatus/App/InputStatusApp.swift +++ b/InputStatus/App/InputStatusApp.swift @@ -5,7 +5,7 @@ import ServiceManagement import SwiftUI @MainActor -final class InputStatusAppDelegate: NSObject, NSApplicationDelegate, @MainActor SPUStandardUserDriverDelegate { +final class InputStatusAppDelegate: NSObject, NSApplicationDelegate, SPUStandardUserDriverDelegate { let model = AppModel() lazy var updaterController = SPUStandardUpdaterController( startingUpdater: true, @@ -37,18 +37,30 @@ final class InputStatusAppDelegate: NSObject, NSApplicationDelegate, @MainActor desktopWidgetController?.toggle() } - func standardUserDriverWillHandleShowingUpdate( + nonisolated func standardUserDriverWillHandleShowingUpdate( _ handleShowingUpdate: Bool, forUpdate update: SUAppcastItem, state: SPUUserUpdateState ) { guard handleShowingUpdate else { return } + Task { @MainActor [weak self] in + self?.beginPresentingUpdate() + } + } + + nonisolated func standardUserDriverWillFinishUpdateSession() { + Task { @MainActor [weak self] in + self?.finishPresentingUpdate() + } + } + + private func beginPresentingUpdate() { updaterIsPresenting = true NSApp.setActivationPolicy(.regular) NSApp.activate() } - func standardUserDriverWillFinishUpdateSession() { + private func finishPresentingUpdate() { guard updaterIsPresenting else { return } updaterIsPresenting = false NSApp.setActivationPolicy(.accessory) From 4c98a80be7f277a56500a50965b815a3f03d6c86 Mon Sep 17 00:00:00 2001 From: Htx <17922172+Houtx@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:12:16 +0800 Subject: [PATCH 5/7] Use Node 24 checkout action --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2fb441..d0759fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: timeout-minutes: 20 steps: - name: Check out repository - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - name: Run core tests shell: /bin/zsh {0} From 2098d3ea88114951990e8bc0539ec59962827fc2 Mon Sep 17 00:00:00 2001 From: Htx <17922172+Houtx@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:14:11 +0800 Subject: [PATCH 6/7] Clean build intermediates on failure --- scripts/build.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/build.sh b/scripts/build.sh index b3854e8..5c422e5 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -47,7 +47,13 @@ if [[ "$module_cache_root" != "$project_root/build/ModuleCache" ]]; then exit 1 fi -rm -rf "$app_bundle" "$arch_build_root" "$module_cache_root" +cleanup_intermediates() { + rm -rf "$arch_build_root" "$module_cache_root" +} +trap cleanup_intermediates EXIT + +rm -rf "$app_bundle" +cleanup_intermediates mkdir -p \ "$app_bundle/Contents/MacOS" \ "$app_bundle/Contents/Frameworks" \ @@ -161,6 +167,4 @@ codesign \ codesign --verify --deep --strict --verbose=2 "$app_bundle" -rm -rf "$arch_build_root" "$module_cache_root" - print "Built: $app_bundle" From 3f0be58e082d69c039746765befc2dabd7637b98 Mon Sep 17 00:00:00 2001 From: Htx <17922172+Houtx@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:34:32 +0800 Subject: [PATCH 7/7] Prepare Input Status 1.5.1 --- InputStatus/Resources/InputStatus-Info.plist | 4 ++-- InputStatus/Resources/InputStatusWidget-Info.plist | 4 ++-- release-notes/v1.5.1.md | 9 +++++++++ 3 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 release-notes/v1.5.1.md diff --git a/InputStatus/Resources/InputStatus-Info.plist b/InputStatus/Resources/InputStatus-Info.plist index c78c246..5d499cd 100644 --- a/InputStatus/Resources/InputStatus-Info.plist +++ b/InputStatus/Resources/InputStatus-Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.5.0 + 1.5.1 CFBundleSupportedPlatforms MacOSX @@ -40,7 +40,7 @@ CFBundleVersion - 6 + 7 LSUIElement LSMinimumSystemVersion diff --git a/InputStatus/Resources/InputStatusWidget-Info.plist b/InputStatus/Resources/InputStatusWidget-Info.plist index 3443983..db7fd82 100644 --- a/InputStatus/Resources/InputStatusWidget-Info.plist +++ b/InputStatus/Resources/InputStatusWidget-Info.plist @@ -23,13 +23,13 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 1.5.0 + 1.5.1 CFBundleSupportedPlatforms MacOSX CFBundleVersion - 6 + 7 CHSDisableImplicitWidgetDiscovery NSExtension diff --git a/release-notes/v1.5.1.md b/release-notes/v1.5.1.md new file mode 100644 index 0000000..dd224a1 --- /dev/null +++ b/release-notes/v1.5.1.md @@ -0,0 +1,9 @@ +# Input Status 1.5.1 + +- 提升状态数据校验与缓存可靠性,异常或过期数据会更准确地提示。 +- 修复 macOS 14 上自动更新回调的兼容性问题。 +- 优化桌面挂件锁定和多显示器位置恢复,减少误拖动和窗口移出屏幕。 +- 统一应用与小组件图标,并完善无完整 Xcode 环境下的构建与测试流程。 +- 加强安装包、更新源和构建清理流程的完整性检查。 + +本版本继续提供 Universal 2 DMG,同时支持 Apple Silicon 和 Intel Mac。