Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ jobs:
uses: actions/upload-artifact@v7
with:
name: OpenJoystickDriver-${{ steps.release.outputs.tag }}
path: .build/release-artifacts/*.zip
path: .build/release-artifacts/*.dmg
if-no-files-found: error
retention-days: 14

Expand All @@ -85,7 +85,7 @@ jobs:
with:
tag_name: ${{ steps.release.outputs.tag }}
name: OpenJoystickDriver ${{ steps.release.outputs.tag }}
files: .build/release-artifacts/*.zip
files: .build/release-artifacts/*.dmg
fail_on_unmatched_files: true
generate_release_notes: true
prerelease: ${{ steps.release.outputs.prerelease }}
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

All notable changes to OpenJoystickDriver are documented in this file.

## 0.2.0

### Added

- Added stock SDL HIDAPI-compatible Xbox 360 rumble path for PCSX2 and SDL apps.
- Added release automation for app bundle versions and drag-and-drop DMG packaging.

### Changed

- Switched release packaging from zip-only distribution to a standard macOS DMG.

## 0.1.0-rc.2

### Added
Expand Down
2 changes: 1 addition & 1 deletion DriverKitExtension/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<key>CFBundlePackageType</key>
<string>DEXT</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<string>0.2.0</string>
<key>CFBundleVersion</key>
<string>107</string>
<key>IOKitPersonalities</key>
Expand Down
19 changes: 19 additions & 0 deletions Sources/OpenJoystickDriver/App/AppModel.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
import Foundation
import OpenJoystickDriverKit

Expand Down Expand Up @@ -48,11 +49,17 @@ struct DeviceViewModel: Identifiable, Hashable, Sendable {
@Published var compatibilityIdentity: String = CompatibilityIdentity.sdl2_3.rawValue
@Published var virtualDeviceDiagnostics: XPCVirtualDeviceDiagnosticsPayload?
@Published var virtualDeviceSelfTest: XPCVirtualDeviceSelfTestPayload?
@Published var updateCheckState: UpdateCheckState = .idle

var developerMode: Bool

var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.2.0"
}

private let client = XPCClient()
private let permissionManager = PermissionManager()
private let updateChecker = UpdateChecker()
private var pollTask: Task<Void, Never>?

init(developerMode: Bool = false) { self.developerMode = developerMode }
Expand All @@ -65,6 +72,7 @@ struct DeviceViewModel: Identifiable, Hashable, Sendable {
await refreshVirtualDeviceDiagnostics()
extensionManager.refreshInstallState()
startPolling()
Task { await checkForUpdates() }
}

func refreshDaemonStatus() { daemonInstalled = DaemonManager.isInstalled }
Expand Down Expand Up @@ -261,6 +269,17 @@ struct DeviceViewModel: Identifiable, Hashable, Sendable {
}
}

func checkForUpdates() async {
updateCheckState = .checking
updateCheckState = await updateChecker.check(currentVersion: appVersion)
}

func openLatestRelease() {
if case .available(let info) = updateCheckState {
NSWorkspace.shared.open(info.htmlURL)
}
}

// MARK: - Private

private func formatDaemonError(_ error: Error) -> String {
Expand Down
86 changes: 86 additions & 0 deletions Sources/OpenJoystickDriver/App/UpdateChecker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import Foundation
import OpenJoystickDriverKit

struct UpdateInfo: Equatable, Sendable {
let tagName: String
let version: SemanticVersion
let htmlURL: URL
}

enum UpdateCheckState: Equatable, Sendable {
case idle
case checking
case upToDate(String)
case available(UpdateInfo)
case failed(String)
}

struct UpdateChecker {
private struct GitHubRelease: Decodable {
let tagName: String
let htmlURL: URL
let draft: Bool

enum CodingKeys: String, CodingKey {
case tagName = "tag_name"
case htmlURL = "html_url"
case draft
}
}

let latestReleaseURL: URL
let session: URLSession

init(
latestReleaseURL: URL = URL(string: "https://api.github.com/repos/xsyetopz/OpenJoystickDriver/releases/latest")!,
session: URLSession = .shared
) {
self.latestReleaseURL = latestReleaseURL
self.session = session
}

func check(currentVersion rawCurrentVersion: String) async -> UpdateCheckState {
guard let currentVersion = SemanticVersion(rawCurrentVersion) else {
return .failed("Current app version is not SemVer: \(rawCurrentVersion)")
}

do {
var request = URLRequest(url: latestReleaseURL)
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
request.setValue("OpenJoystickDriver", forHTTPHeaderField: "User-Agent")

let (data, response) = try await data(for: request)
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
return .failed("GitHub returned HTTP \(http.statusCode)")
}

let release = try JSONDecoder().decode(GitHubRelease.self, from: data)
guard !release.draft else { return .failed("Latest GitHub release is a draft") }
guard let latestVersion = SemanticVersion(release.tagName) else {
return .failed("Latest GitHub release tag is not SemVer: \(release.tagName)")
}

let info = UpdateInfo(tagName: release.tagName, version: latestVersion, htmlURL: release.htmlURL)
return latestVersion > currentVersion ? .available(info) : .upToDate(rawCurrentVersion)
} catch {
return .failed(error.localizedDescription)
}
}

private func data(for request: URLRequest) async throws -> (Data, URLResponse) {
try await withCheckedThrowingContinuation { continuation in
let task = session.dataTask(with: request) { data, response, error in
if let error {
continuation.resume(throwing: error)
return
}
guard let data, let response else {
continuation.resume(throwing: URLError(.badServerResponse))
return
}
continuation.resume(returning: (data, response))
}
task.resume()
}
}
}
4 changes: 2 additions & 2 deletions Sources/OpenJoystickDriver/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ struct CLI {
case "uninstall": UninstallCommand().run()
case "run": RunCommand().run()
case "--help", "-h", "help": printHelp()
case "--version", "-v", "version": print("OpenJoystickDriver v0.1.0-rc.2")
case "--version", "-v", "version": print("OpenJoystickDriver v0.2.0")
default:
print("Unknown command: \(command)")
printHelp()
Expand All @@ -33,7 +33,7 @@ struct CLI {
private func printHelp() {
print(
"""
OpenJoystickDriver v0.1.0-rc.2 \
OpenJoystickDriver v0.2.0 \
- macOS gamepad driver

Usage: OpenJoystickDriver \
Expand Down
45 changes: 45 additions & 0 deletions Sources/OpenJoystickDriver/Views/MenuBarPopoverView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ struct MenuBarPopoverView: View {
inputTestRow
selfTestRow
Divider()
updateRow
Divider()
footerRow
}
.padding(12)
Expand Down Expand Up @@ -367,6 +369,49 @@ struct MenuBarPopoverView: View {
}
.font(.caption)
}

private var updateRow: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Text("Updates").font(.subheadline)
Spacer()
SwiftUI.Button(updateButtonTitle) {
Task { await model.checkForUpdates() }
}
.buttonStyle(.borderless)
.controlSize(.small)
.disabled(model.updateCheckState == .checking)
}

switch model.updateCheckState {
case .idle:
Text("Current version: \(model.appVersion)").font(.caption).foregroundColor(.secondary)
case .checking:
Text("Checking GitHub releases…").font(.caption).foregroundColor(.secondary)
case .upToDate(let version):
Text("OpenJoystickDriver \(version) is current.").font(.caption).foregroundColor(.green)
case .available(let info):
HStack(spacing: 8) {
Text("OpenJoystickDriver \(info.tagName) is available.")
.font(.caption)
.foregroundColor(.orange)
Spacer()
SwiftUI.Button("Open") { model.openLatestRelease() }
.buttonStyle(.borderless)
.controlSize(.small)
}
case .failed(let message):
Text("Update check failed: \(message)")
.font(.caption)
.foregroundColor(.orange)
.fixedSize(horizontal: false, vertical: true)
}
}
}

private var updateButtonTitle: String {
model.updateCheckState == .checking ? "Checking…" : "Check"
}
}

@MainActor private final class InputTestWindowController {
Expand Down
129 changes: 129 additions & 0 deletions Sources/OpenJoystickDriverKit/Update/SemanticVersion.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import Foundation

public struct SemanticVersion: Comparable, Equatable, Sendable {
public let major: Int
public let minor: Int
public let patch: Int
public let prerelease: [String]

public init?(_ value: String) {
var version = value
if version.first == "v" || version.first == "V" {
version.removeFirst()
}

if let buildStart = version.firstIndex(of: "+") {
let build = String(version[version.index(after: buildStart)...])
guard Self.isValidBuildMetadata(build) else { return nil }
version = String(version[..<buildStart])
}

let versionAndPrerelease = version.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false)
guard let core = versionAndPrerelease.first else { return nil }

let numbers = core.split(separator: ".", omittingEmptySubsequences: false)
guard numbers.count == 3,
Self.isValidNumericIdentifier(String(numbers[0])),
Self.isValidNumericIdentifier(String(numbers[1])),
Self.isValidNumericIdentifier(String(numbers[2])),
let major = Int(numbers[0]),
let minor = Int(numbers[1]),
let patch = Int(numbers[2]),
major >= 0,
minor >= 0,
patch >= 0 else {
return nil
}

let prerelease: [String]
if versionAndPrerelease.count == 2 {
prerelease = versionAndPrerelease[1].split(separator: ".", omittingEmptySubsequences: false).map(String.init)
guard !prerelease.isEmpty, prerelease.allSatisfy(Self.isValidIdentifier) else { return nil }
} else {
prerelease = []
}

self.major = major
self.minor = minor
self.patch = patch
self.prerelease = prerelease
}

public static func < (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool {
if lhs.major != rhs.major { return lhs.major < rhs.major }
if lhs.minor != rhs.minor { return lhs.minor < rhs.minor }
if lhs.patch != rhs.patch { return lhs.patch < rhs.patch }
return comparePrerelease(lhs.prerelease, rhs.prerelease) == .orderedAscending
}

private static func comparePrerelease(_ lhs: [String], _ rhs: [String]) -> ComparisonResult {
if lhs.isEmpty && rhs.isEmpty { return .orderedSame }
if lhs.isEmpty { return .orderedDescending }
if rhs.isEmpty { return .orderedAscending }

for index in 0..<min(lhs.count, rhs.count) {
let comparison = compareIdentifier(lhs[index], rhs[index])
if comparison != .orderedSame { return comparison }
}

if lhs.count == rhs.count { return .orderedSame }
return lhs.count < rhs.count ? .orderedAscending : .orderedDescending
}

private static func compareIdentifier(_ lhs: String, _ rhs: String) -> ComparisonResult {
let lhsIsNumber = Self.isNumericIdentifier(lhs)
let rhsIsNumber = Self.isNumericIdentifier(rhs)

switch (lhsIsNumber, rhsIsNumber) {
case (true, true):
if lhs.count != rhs.count {
return lhs.count < rhs.count ? .orderedAscending : .orderedDescending
}
let comparison = lhs.compare(rhs, options: [], range: nil, locale: Locale(identifier: "en_US_POSIX"))
if comparison == .orderedSame { return .orderedSame }
return comparison == .orderedAscending ? .orderedAscending : .orderedDescending
case (true, false):
return .orderedAscending
case (false, true):
return .orderedDescending
case (false, false):
let comparison = lhs.compare(rhs, options: [], range: nil, locale: Locale(identifier: "en_US_POSIX"))
if comparison == .orderedSame { return .orderedSame }
return comparison == .orderedAscending ? .orderedAscending : .orderedDescending
}
}

private static func isValidIdentifier(_ identifier: String) -> Bool {
guard !identifier.isEmpty else { return false }
guard identifier.unicodeScalars.allSatisfy(Self.isValidIdentifierScalar) else { return false }
return !Self.isNumericIdentifierWithLeadingZero(identifier)
}

private static func isValidBuildMetadata(_ buildMetadata: String) -> Bool {
let identifiers = buildMetadata.split(separator: ".", omittingEmptySubsequences: false).map(String.init)
return !identifiers.isEmpty && identifiers.allSatisfy { identifier in
!identifier.isEmpty && identifier.unicodeScalars.allSatisfy(Self.isValidIdentifierScalar)
}
}

private static func isValidNumericIdentifier(_ identifier: String) -> Bool {
Self.isNumericIdentifier(identifier) && !Self.isNumericIdentifierWithLeadingZero(identifier)
}

private static func isNumericIdentifier(_ identifier: String) -> Bool {
!identifier.isEmpty && identifier.unicodeScalars.allSatisfy { scalar in
("0"..."9").contains(scalar)
}
}

private static func isNumericIdentifierWithLeadingZero(_ identifier: String) -> Bool {
identifier.count > 1 && identifier.first == "0" && Self.isNumericIdentifier(identifier)
}

private static func isValidIdentifierScalar(_ scalar: Unicode.Scalar) -> Bool {
("0"..."9").contains(scalar) ||
("A"..."Z").contains(scalar) ||
("a"..."z").contains(scalar) ||
scalar == "-"
}
}
Loading
Loading