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
98 changes: 98 additions & 0 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
name: Nightly macOS Release

on:
schedule:
- cron: '0 6 * * *' # Daily at 06:00 UTC
workflow_dispatch:

env:
APP_NAME: OrbitDock
CHANNEL: nightly

permissions:
contents: write

concurrency:
group: nightly-release
cancel-in-progress: true

jobs:
nightly:
runs-on: macos-15
timeout-minutes: 45

steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '16.3'

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable

- name: Compute nightly version
id: version
run: |
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
BASE_VERSION="${LATEST_TAG#v}"
DATE_STAMP=$(date -u +%Y%m%d)
NIGHTLY_VERSION="${BASE_VERSION}-nightly.${DATE_STAMP}"
BUILD_NUMBER="${DATE_STAMP}01"
echo "version=$NIGHTLY_VERSION" >> $GITHUB_OUTPUT
echo "build_number=$BUILD_NUMBER" >> $GITHUB_OUTPUT
echo "Nightly version: $NIGHTLY_VERSION (build $BUILD_NUMBER)"

- name: Set Xcode version numbers
run: |
cd OrbitDockNative
agvtool new-marketing-version "${{ steps.version.outputs.version }}"
agvtool new-version -all "${{ steps.version.outputs.build_number }}"

- name: Archive, notarize, and package
env:
VERSION: ${{ steps.version.outputs.version }}
CHANNEL: nightly
SPARKLE_PUBLIC_ED_KEY: ${{ secrets.SPARKLE_PUBLIC_ED_KEY }}
MACOS_DEVELOPMENT_TEAM: ${{ secrets.MACOS_DEVELOPMENT_TEAM }}
APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }}
APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }}
APPLE_NOTARY_PRIVATE_KEY: ${{ secrets.APPLE_NOTARY_PRIVATE_KEY }}
run: ./scripts/archive-macos-app-release.sh

- name: Generate nightly appcast
env:
SITE_DIR: ${{ github.workspace }}/dist
RELEASE_TAG: nightly
CHANNEL: nightly
SPARKLE_PRIVATE_ED_KEY: ${{ secrets.SPARKLE_PRIVATE_ED_KEY }}
DOWNLOAD_URL_PREFIX: https://github.com/${{ github.repository }}/releases/download/nightly/
run: ./scripts/generate-sparkle-appcast.sh

- name: Publish to nightly release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${{ steps.version.outputs.version }}"

# Delete existing nightly release/tag if present
gh release delete nightly --yes 2>/dev/null || true
git push origin :refs/tags/nightly 2>/dev/null || true

# Create fresh nightly prerelease
gh release create nightly \
--title "$APP_NAME Nightly ($VERSION)" \
--notes "Automated nightly build from \`main\` at $(date -u +%Y-%m-%d).

**Version:** $VERSION
**Commit:** ${{ github.sha }}

> Nightly builds may be unstable. Use Stable or Beta channels for production." \
--prerelease \
dist/${APP_NAME}-${VERSION}.zip \
dist/${APP_NAME}-${VERSION}.zip.sha256 \
dist/appcast-nightly.xml
2 changes: 1 addition & 1 deletion OrbitDockNative/OrbitDock/OrbitDockApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ struct OrbitDockApp: App {
}

Settings {
SettingsView()
SettingsView(appUpdater: appUpdater)
.environment(\.serverManager, appRuntime.serverManager)
.environment(appRuntime.runtimeRegistry.activeSessionStore)
.environment(\.modelPricingService, modelPricingService)
Expand Down
102 changes: 101 additions & 1 deletion OrbitDockNative/OrbitDock/Services/AppUpdate/AppUpdater.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,81 @@ import Foundation
#if os(macOS)
import Sparkle

enum UpdateChannel: String, CaseIterable, Identifiable {
case stable
case beta
case nightly

var id: String { rawValue }

var displayName: String {
switch self {
case .stable: "Stable"
case .beta: "Beta"
case .nightly: "Nightly"
}
}

var description: String {
switch self {
case .stable:
"Production releases only."
case .beta:
"Pre-release builds for testing upcoming features."
case .nightly:
"Latest automated builds β€” may be unstable."
}
}

/// Sparkle channel identifiers sent via `allowedChannels(for:)`.
/// Stable returns empty (matches items with no channel tag).
var sparkleChannels: Set<String> {
switch self {
case .stable: []
case .beta: ["beta"]
case .nightly: ["nightly"]
}
}

/// The appcast filename for this channel.
var appcastFilename: String {
switch self {
case .stable: "appcast.xml"
case .beta: "appcast-beta.xml"
case .nightly: "appcast-nightly.xml"
}
}

/// Feed URL for this channel on GitHub Releases.
func feedURL(owner: String = "Robdel12", repo: String = "OrbitDock") -> URL? {
let base = "https://github.com/\(owner)/\(repo)/releases"
let urlString: String
switch self {
case .stable:
urlString = "\(base)/latest/download/\(appcastFilename)"
case .beta:
urlString = "\(base)/latest/download/\(appcastFilename)"
case .nightly:
urlString = "\(base)/download/nightly/\(appcastFilename)"
}
return URL(string: urlString)
}
}

@MainActor
@Observable
final class AppUpdater {
private(set) var isConfigured: Bool

var selectedChannel: UpdateChannel {
didSet {
UserDefaults.standard.set(selectedChannel.rawValue, forKey: "updateChannel")
applyChannelFeedURL()
}
}

@ObservationIgnored private let updaterController: SPUStandardUpdaterController?
@ObservationIgnored private let updaterDelegate: AppUpdaterDelegate?
@ObservationIgnored private var hasStarted = false

init(bundle: Bundle = .main) {
Expand All @@ -17,13 +86,20 @@ import Foundation
let isConfigured = !feedURL.isEmpty && !publicKey.isEmpty
self.isConfigured = isConfigured

let storedChannel = UserDefaults.standard.string(forKey: "updateChannel") ?? "stable"
let channel = UpdateChannel(rawValue: storedChannel) ?? .stable
self.selectedChannel = channel

if isConfigured {
let delegate = AppUpdaterDelegate(channel: channel)
self.updaterDelegate = delegate
updaterController = SPUStandardUpdaterController(
startingUpdater: false,
updaterDelegate: nil,
updaterDelegate: delegate,
userDriverDelegate: nil
)
} else {
self.updaterDelegate = nil
updaterController = nil
}
}
Expand All @@ -34,6 +110,7 @@ import Foundation

func start() {
guard !hasStarted, let updaterController else { return }
applyChannelFeedURL()
hasStarted = true
updaterController.startUpdater()
}
Expand All @@ -43,11 +120,34 @@ import Foundation
updaterController.checkForUpdates(nil)
}

private func applyChannelFeedURL() {
updaterDelegate?.channel = selectedChannel
}

private static func infoString(_ key: String, bundle: Bundle) -> String {
guard let value = bundle.object(forInfoDictionaryKey: key) as? String else {
return ""
}
return value.trimmingCharacters(in: .whitespacesAndNewlines)
}
}

// MARK: - Sparkle Delegate

final class AppUpdaterDelegate: NSObject, SPUUpdaterDelegate {
var channel: UpdateChannel

init(channel: UpdateChannel) {
self.channel = channel
super.init()
}

func allowedChannels(for updater: SPUUpdater) -> Set<String> {
channel.sparkleChannels
}

func feedURLString(for updater: SPUUpdater) -> String? {
channel.feedURL()?.absoluteString
}
}
#endif
14 changes: 14 additions & 0 deletions OrbitDockNative/OrbitDock/Views/Settings/SettingsGeneralView.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
import SwiftUI

struct GeneralSettingsView: View {
#if os(macOS)
let appUpdater: AppUpdater?
#endif
@State private var openAiNamingModel = SettingsOpenAiNamingModel()

#if os(macOS)
init(appUpdater: AppUpdater? = nil) {
self.appUpdater = appUpdater
}
#endif

var body: some View {
ScrollView {
VStack(spacing: Spacing.xl) {
#if os(macOS)
if let appUpdater {
SettingsUpdateChannelSection(appUpdater: appUpdater)
}
#endif
SettingsEditorPreferencesSection()
SettingsOpenAiNamingSection(model: openAiNamingModel)
SettingsDictationSection()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import SwiftUI

#if os(macOS)
struct SettingsUpdateChannelSection: View {
@Bindable var appUpdater: AppUpdater

var body: some View {
SettingsSection(title: "UPDATES", icon: "arrow.triangle.2.circlepath") {
VStack(alignment: .leading, spacing: Spacing.lg_) {
VStack(alignment: .leading, spacing: Spacing.sm_) {
HStack {
Text("Update Channel")
.font(.system(size: TypeScale.body))

Spacer()

Picker("", selection: $appUpdater.selectedChannel) {
ForEach(UpdateChannel.allCases) { channel in
Text(channel.displayName).tag(channel)
}
}
.pickerStyle(.menu)
.frame(width: 140)
.tint(Color.accent)
}

Text(appUpdater.selectedChannel.description)
.font(.system(size: TypeScale.meta))
.foregroundStyle(Color.textTertiary)
}

if appUpdater.selectedChannel != .stable {
Divider()
.foregroundStyle(Color.panelBorder)

HStack(spacing: Spacing.sm) {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(Color.statusPermission)
Text(
"Non-stable channels may include incomplete features or breaking changes."
)
.font(.system(size: TypeScale.meta))
.foregroundStyle(Color.textTertiary)
}
}
}
}
}
}
#endif
Loading
Loading