diff --git a/.gitignore b/.gitignore
index b7c6dd7..64342a4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@
xcuserdata/
DerivedData/
.DS_Store
+dist/
diff --git a/Package.swift b/Package.swift
index 5c7473c..cb50bce 100644
--- a/Package.swift
+++ b/Package.swift
@@ -6,6 +6,10 @@ let package = Package(
platforms: [
.macOS(.v26)
],
+ products: [
+ .executable(name: "AppleIntelligenceServer", targets: ["AppleIntelligenceServer"]),
+ .executable(name: "AppleIntelligenceApp", targets: ["AppleIntelligenceApp"]),
+ ],
dependencies: [
.package(url: "https://github.com/grpc/grpc-swift.git", from: "2.0.0"),
.package(url: "https://github.com/grpc/grpc-swift-nio-transport.git", from: "1.0.0"),
@@ -14,18 +18,42 @@ let package = Package(
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"),
],
targets: [
- .executableTarget(
- name: "AppleIntelligenceGRPC",
+ // Shared library with gRPC service code
+ .target(
+ name: "AppleIntelligenceCore",
dependencies: [
.product(name: "GRPCCore", package: "grpc-swift"),
.product(name: "GRPCNIOTransportHTTP2", package: "grpc-swift-nio-transport"),
.product(name: "GRPCProtobuf", package: "grpc-swift-protobuf"),
.product(name: "SwiftProtobuf", package: "swift-protobuf"),
+ ],
+ swiftSettings: [
+ .unsafeFlags(["-Xfrontend", "-suppress-warnings"])
+ ]
+ ),
+ // CLI server (original)
+ .executableTarget(
+ name: "AppleIntelligenceServer",
+ dependencies: [
+ "AppleIntelligenceCore",
.product(name: "ArgumentParser", package: "swift-argument-parser"),
],
swiftSettings: [
.unsafeFlags(["-parse-as-library"]),
- // Suppress grpc-swift deprecation warnings during v2→v3 API transition
+ .unsafeFlags(["-Xfrontend", "-suppress-warnings"])
+ ]
+ ),
+ // Menu bar app
+ .executableTarget(
+ name: "AppleIntelligenceApp",
+ dependencies: [
+ "AppleIntelligenceCore",
+ ],
+ exclude: [
+ "Info.plist"
+ ],
+ swiftSettings: [
+ .unsafeFlags(["-parse-as-library"]),
.unsafeFlags(["-Xfrontend", "-suppress-warnings"])
]
),
diff --git a/Sources/AppleIntelligenceApp/App.swift b/Sources/AppleIntelligenceApp/App.swift
new file mode 100644
index 0000000..5a47b93
--- /dev/null
+++ b/Sources/AppleIntelligenceApp/App.swift
@@ -0,0 +1,122 @@
+import SwiftUI
+
+@main
+struct AppleIntelligenceApp: App {
+ @State private var settings = AppSettings()
+ @State private var serverManager: ServerManager?
+ @State private var chatViewModel = ChatViewModel()
+ @State private var didAutoStart = false
+
+ var body: some Scene {
+ MenuBarExtra {
+ if let serverManager {
+ MenuView(
+ serverManager: serverManager,
+ settings: settings
+ )
+ }
+ } label: {
+ Image(systemName: serverManager?.state.isRunning == true ? "brain.fill" : "brain")
+ .onAppear {
+ // Auto-start server on app launch if enabled
+ if !didAutoStart {
+ didAutoStart = true
+ if settings.autoStartServer, let serverManager {
+ serverManager.start()
+ }
+ }
+ }
+ }
+
+ Window("Chat", id: "chat") {
+ ChatView(viewModel: chatViewModel)
+ }
+ .defaultSize(width: 500, height: 600)
+
+ Window("Settings", id: "settings") {
+ SettingsView(settings: settings)
+ }
+ .windowResizability(.contentSize)
+ }
+
+ init() {
+ let settings = AppSettings()
+ _settings = State(initialValue: settings)
+ _serverManager = State(initialValue: ServerManager(settings: settings))
+ }
+}
+
+struct MenuView: View {
+ @Bindable var serverManager: ServerManager
+ @Bindable var settings: AppSettings
+ @Environment(\.openWindow) private var openWindow
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ // Status section
+ HStack {
+ Circle()
+ .fill(statusColor)
+ .frame(width: 8, height: 8)
+ Text(serverManager.state.statusText)
+ .font(.headline)
+ }
+ .padding(.bottom, 4)
+
+ // Model status
+ Text("Model: \(modelStatusText)")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ Divider()
+ .padding(.vertical, 4)
+
+ // Toggle button
+ Button(serverManager.state.isRunning ? "Stop Server" : "Start Server") {
+ serverManager.toggle()
+ }
+ .keyboardShortcut("s", modifiers: .command)
+
+ Divider()
+ .padding(.vertical, 4)
+
+ Button("Open Chat...") {
+ openWindow(id: "chat")
+ NSApp.activate(ignoringOtherApps: true)
+ }
+ .keyboardShortcut("c", modifiers: .command)
+
+ Button("Settings...") {
+ openWindow(id: "settings")
+ NSApp.activate(ignoringOtherApps: true)
+ }
+ .keyboardShortcut(",", modifiers: .command)
+
+ Divider()
+ .padding(.vertical, 4)
+
+ Button("Quit") {
+ NSApplication.shared.terminate(nil)
+ }
+ .keyboardShortcut("q", modifiers: .command)
+ }
+ .padding(8)
+ }
+
+ private var statusColor: Color {
+ switch serverManager.state {
+ case .stopped:
+ return .gray
+ case .starting:
+ return .yellow
+ case .running:
+ return .green
+ case .error:
+ return .red
+ }
+ }
+
+ private var modelStatusText: String {
+ "Internal"
+ }
+}
diff --git a/Sources/AppleIntelligenceApp/Info.plist b/Sources/AppleIntelligenceApp/Info.plist
new file mode 100644
index 0000000..6eec68c
--- /dev/null
+++ b/Sources/AppleIntelligenceApp/Info.plist
@@ -0,0 +1,18 @@
+
+
+
+
+ LSUIElement
+
+ CFBundleName
+ Apple Intelligence Server
+ CFBundleIdentifier
+ com.svrnty.apple-intelligence-server
+ CFBundleVersion
+ 1.0.0
+ CFBundleShortVersionString
+ 1.0
+ NSLocalNetworkUsageDescription
+ Apple Intelligence Server needs local network access to accept connections from other devices.
+
+
diff --git a/Sources/AppleIntelligenceApp/Models/AppSettings.swift b/Sources/AppleIntelligenceApp/Models/AppSettings.swift
new file mode 100644
index 0000000..46d17a5
--- /dev/null
+++ b/Sources/AppleIntelligenceApp/Models/AppSettings.swift
@@ -0,0 +1,52 @@
+import Foundation
+import ServiceManagement
+
+@Observable
+final class AppSettings {
+ var host: String {
+ didSet { UserDefaults.standard.set(host, forKey: "grpc_host") }
+ }
+
+ var port: Int {
+ didSet { UserDefaults.standard.set(port, forKey: "grpc_port") }
+ }
+
+ var apiKey: String {
+ didSet { UserDefaults.standard.set(apiKey, forKey: "api_key") }
+ }
+
+ var autoStartServer: Bool {
+ didSet { UserDefaults.standard.set(autoStartServer, forKey: "auto_start_server") }
+ }
+
+ var launchAtLogin: Bool {
+ didSet {
+ do {
+ if launchAtLogin {
+ try SMAppService.mainApp.register()
+ } else {
+ try SMAppService.mainApp.unregister()
+ }
+ } catch {
+ print("Failed to update launch at login: \(error)")
+ }
+ }
+ }
+
+ init() {
+ self.host = UserDefaults.standard.string(forKey: "grpc_host") ?? "0.0.0.0"
+ let savedPort = UserDefaults.standard.integer(forKey: "grpc_port")
+ self.port = savedPort == 0 ? 50051 : savedPort
+ self.apiKey = UserDefaults.standard.string(forKey: "api_key") ?? ""
+ self.autoStartServer = UserDefaults.standard.bool(forKey: "auto_start_server")
+ self.launchAtLogin = SMAppService.mainApp.status == .enabled
+ }
+
+ func resetToDefaults() {
+ host = "0.0.0.0"
+ port = 50051
+ apiKey = ""
+ autoStartServer = false
+ launchAtLogin = false
+ }
+}
diff --git a/Sources/AppleIntelligenceApp/Models/ChatMessage.swift b/Sources/AppleIntelligenceApp/Models/ChatMessage.swift
new file mode 100644
index 0000000..fb93efd
--- /dev/null
+++ b/Sources/AppleIntelligenceApp/Models/ChatMessage.swift
@@ -0,0 +1,22 @@
+import Foundation
+
+struct ChatMessage: Identifiable, Equatable {
+ let id: UUID
+ let role: Role
+ var content: String
+ let timestamp: Date
+ var isStreaming: Bool
+
+ enum Role: Equatable {
+ case user
+ case assistant
+ }
+
+ init(role: Role, content: String, isStreaming: Bool = false) {
+ self.id = UUID()
+ self.role = role
+ self.content = content
+ self.timestamp = Date()
+ self.isStreaming = isStreaming
+ }
+}
diff --git a/Sources/AppleIntelligenceApp/ServerManager.swift b/Sources/AppleIntelligenceApp/ServerManager.swift
new file mode 100644
index 0000000..7fb414d
--- /dev/null
+++ b/Sources/AppleIntelligenceApp/ServerManager.swift
@@ -0,0 +1,123 @@
+import Foundation
+import AppleIntelligenceCore
+import GRPCCore
+import GRPCNIOTransportHTTP2
+
+@MainActor
+@Observable
+final class ServerManager {
+ enum ServerState: Equatable {
+ case stopped
+ case starting
+ case running(host: String, port: Int)
+ case error(String)
+
+ var isRunning: Bool {
+ if case .running = self { return true }
+ return false
+ }
+
+ var statusText: String {
+ switch self {
+ case .stopped:
+ return "Server offline"
+ case .starting:
+ return "Starting..."
+ case .running(let host, let port):
+ return "Running on \(host):\(port)"
+ case .error:
+ return "Server offline"
+ }
+ }
+ }
+
+ private(set) var state: ServerState = .stopped
+ private(set) var modelStatus: String = "Unknown"
+
+ private var serverTask: Task?
+ private var service: AppleIntelligenceService?
+ private let settings: AppSettings
+
+ init(settings: AppSettings) {
+ self.settings = settings
+ }
+
+ func start() {
+ guard !state.isRunning else { return }
+
+ state = .starting
+
+ // Capture settings values for use in Task
+ let host = settings.host
+ let port = settings.port
+ let apiKey = settings.apiKey.isEmpty ? nil : settings.apiKey
+
+ serverTask = Task {
+ do {
+ // Initialize Apple Intelligence service
+ let aiService = await AppleIntelligenceService()
+ self.service = aiService
+
+ let isAvailable = await aiService.isAvailable
+ let status = await aiService.getModelStatus()
+
+ await MainActor.run {
+ self.modelStatus = status
+ }
+
+ guard isAvailable else {
+ await MainActor.run {
+ self.state = .error("Apple Intelligence not available")
+ }
+ return
+ }
+
+ // Create provider
+ let provider = AppleIntelligenceProvider(service: aiService, apiKey: apiKey)
+
+ // Create transport and server
+ let transport = HTTP2ServerTransport.Posix(
+ address: .ipv4(host: host, port: port),
+ transportSecurity: .plaintext,
+ config: .defaults
+ )
+
+ let server = GRPCServer(transport: transport, services: [provider])
+
+ await MainActor.run {
+ self.state = .running(host: host, port: port)
+ }
+
+ // Run server until cancelled
+ try await server.serve()
+
+ } catch is CancellationError {
+ // Normal shutdown
+ } catch {
+ await MainActor.run {
+ self.state = .error(error.localizedDescription)
+ }
+ }
+
+ await MainActor.run {
+ if case .running = self.state {
+ self.state = .stopped
+ }
+ }
+ }
+ }
+
+ func stop() {
+ serverTask?.cancel()
+ serverTask = nil
+ state = .stopped
+ }
+
+ func toggle() {
+ if state.isRunning {
+ stop()
+ } else {
+ start()
+ }
+ }
+}
diff --git a/Sources/AppleIntelligenceApp/ViewModels/ChatViewModel.swift b/Sources/AppleIntelligenceApp/ViewModels/ChatViewModel.swift
new file mode 100644
index 0000000..0a92ff7
--- /dev/null
+++ b/Sources/AppleIntelligenceApp/ViewModels/ChatViewModel.swift
@@ -0,0 +1,96 @@
+import Foundation
+import AppleIntelligenceCore
+
+@MainActor
+@Observable
+final class ChatViewModel {
+ var messages: [ChatMessage] = []
+ var inputText: String = ""
+ var isLoading: Bool = false
+ var errorMessage: String?
+
+ private var service: AppleIntelligenceService?
+ private var currentTask: Task?
+
+ func initialize() async {
+ service = await AppleIntelligenceService()
+ }
+
+ var isServiceAvailable: Bool {
+ get async {
+ await service?.isAvailable ?? false
+ }
+ }
+
+ func sendMessage() {
+ let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !text.isEmpty else { return }
+ guard !isLoading else { return }
+
+ // Add user message
+ let userMessage = ChatMessage(role: .user, content: text)
+ messages.append(userMessage)
+ inputText = ""
+ errorMessage = nil
+
+ // Add placeholder for assistant response
+ var assistantMessage = ChatMessage(role: .assistant, content: "", isStreaming: true)
+ messages.append(assistantMessage)
+
+ isLoading = true
+
+ currentTask = Task {
+ do {
+ guard let service = service else {
+ throw AppleIntelligenceError.modelNotAvailable
+ }
+
+ let stream = await service.streamComplete(
+ prompt: text,
+ temperature: nil,
+ maxTokens: nil
+ )
+
+ var fullResponse = ""
+ for try await partialResponse in stream {
+ fullResponse = partialResponse
+ // Update the last message (assistant's response)
+ if let index = messages.lastIndex(where: { $0.role == .assistant }) {
+ messages[index].content = fullResponse
+ }
+ }
+
+ // Mark streaming as complete
+ if let index = messages.lastIndex(where: { $0.role == .assistant }) {
+ messages[index].isStreaming = false
+ }
+
+ } catch {
+ errorMessage = error.localizedDescription
+ // Remove the empty assistant message on error
+ if let index = messages.lastIndex(where: { $0.role == .assistant && $0.content.isEmpty }) {
+ messages.remove(at: index)
+ }
+ }
+
+ isLoading = false
+ }
+ }
+
+ func stopGeneration() {
+ currentTask?.cancel()
+ currentTask = nil
+ isLoading = false
+
+ // Mark any streaming message as complete
+ if let index = messages.lastIndex(where: { $0.isStreaming }) {
+ messages[index].isStreaming = false
+ }
+ }
+
+ func clearChat() {
+ stopGeneration()
+ messages.removeAll()
+ errorMessage = nil
+ }
+}
diff --git a/Sources/AppleIntelligenceApp/Views/ChatView.swift b/Sources/AppleIntelligenceApp/Views/ChatView.swift
new file mode 100644
index 0000000..29c67c4
--- /dev/null
+++ b/Sources/AppleIntelligenceApp/Views/ChatView.swift
@@ -0,0 +1,173 @@
+import SwiftUI
+
+struct ChatView: View {
+ @Bindable var viewModel: ChatViewModel
+ @FocusState private var isInputFocused: Bool
+
+ var body: some View {
+ VStack(spacing: 0) {
+ // Messages list
+ ScrollViewReader { proxy in
+ ScrollView {
+ LazyVStack(spacing: 12) {
+ ForEach(viewModel.messages) { message in
+ MessageBubble(message: message)
+ .id(message.id)
+ }
+ }
+ .padding()
+ }
+ .onChange(of: viewModel.messages.count) { _, _ in
+ if let lastMessage = viewModel.messages.last {
+ withAnimation {
+ proxy.scrollTo(lastMessage.id, anchor: .bottom)
+ }
+ }
+ }
+ .onChange(of: viewModel.messages.last?.content) { _, _ in
+ if let lastMessage = viewModel.messages.last {
+ withAnimation {
+ proxy.scrollTo(lastMessage.id, anchor: .bottom)
+ }
+ }
+ }
+ }
+
+ // Error message
+ if let error = viewModel.errorMessage {
+ HStack {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.yellow)
+ Text(error)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Spacer()
+ Button("Dismiss") {
+ viewModel.errorMessage = nil
+ }
+ .buttonStyle(.plain)
+ .font(.caption)
+ }
+ .padding(.horizontal)
+ .padding(.vertical, 8)
+ .background(.red.opacity(0.1))
+ }
+
+ Divider()
+
+ // Input area
+ HStack(spacing: 12) {
+ TextField("Message...", text: $viewModel.inputText, axis: .vertical)
+ .textFieldStyle(.plain)
+ .lineLimit(1...5)
+ .focused($isInputFocused)
+ .onSubmit {
+ if !viewModel.inputText.isEmpty {
+ viewModel.sendMessage()
+ }
+ }
+
+ if viewModel.isLoading {
+ Button {
+ viewModel.stopGeneration()
+ } label: {
+ Image(systemName: "stop.circle.fill")
+ .font(.title2)
+ .foregroundStyle(.red)
+ }
+ .buttonStyle(.plain)
+ } else {
+ Button {
+ viewModel.sendMessage()
+ } label: {
+ Image(systemName: "arrow.up.circle.fill")
+ .font(.title2)
+ .foregroundStyle(viewModel.inputText.isEmpty ? .gray : .accentColor)
+ }
+ .buttonStyle(.plain)
+ .disabled(viewModel.inputText.isEmpty)
+ }
+ }
+ .padding()
+ }
+ .frame(minWidth: 400, minHeight: 500)
+ .toolbar {
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ viewModel.clearChat()
+ } label: {
+ Image(systemName: "trash")
+ }
+ .help("Clear chat")
+ .disabled(viewModel.messages.isEmpty)
+ }
+ }
+ .task {
+ await viewModel.initialize()
+ }
+ .onAppear {
+ // Force the app to become active and accept keyboard input
+ NSApp.setActivationPolicy(.regular)
+ NSApp.activate(ignoringOtherApps: true)
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
+ // Make sure the window is key
+ if let window = NSApp.windows.first(where: { $0.title == "Chat" }) {
+ window.makeKeyAndOrderFront(nil)
+ }
+ isInputFocused = true
+ }
+ }
+ .onDisappear {
+ // Return to accessory mode when chat is closed
+ if NSApp.windows.filter({ $0.isVisible && $0.title != "" }).isEmpty {
+ NSApp.setActivationPolicy(.accessory)
+ }
+ }
+ }
+}
+
+struct MessageBubble: View {
+ let message: ChatMessage
+
+ var body: some View {
+ HStack {
+ if message.role == .user {
+ Spacer(minLength: 60)
+ }
+
+ VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 4) {
+ Text(message.content)
+ .textSelection(.enabled)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ .background(bubbleColor)
+ .foregroundStyle(message.role == .user ? .white : .primary)
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+
+ if message.isStreaming {
+ HStack(spacing: 4) {
+ ProgressView()
+ .scaleEffect(0.6)
+ Text("Generating...")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+
+ if message.role == .assistant {
+ Spacer(minLength: 60)
+ }
+ }
+ }
+
+ private var bubbleColor: Color {
+ switch message.role {
+ case .user:
+ return .accentColor
+ case .assistant:
+ return Color(nsColor: .controlBackgroundColor)
+ }
+ }
+}
diff --git a/Sources/AppleIntelligenceApp/Views/SettingsView.swift b/Sources/AppleIntelligenceApp/Views/SettingsView.swift
new file mode 100644
index 0000000..b2e1390
--- /dev/null
+++ b/Sources/AppleIntelligenceApp/Views/SettingsView.swift
@@ -0,0 +1,58 @@
+import SwiftUI
+
+struct SettingsView: View {
+ @Bindable var settings: AppSettings
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ Form {
+ Section("Server Configuration") {
+ TextField("Host", text: $settings.host)
+ .textFieldStyle(.roundedBorder)
+
+ TextField("Port", value: $settings.port, format: .number)
+ .textFieldStyle(.roundedBorder)
+
+ SecureField("API Key (optional)", text: $settings.apiKey)
+ .textFieldStyle(.roundedBorder)
+ }
+
+ Section("Behavior") {
+ Toggle("Launch at login", isOn: $settings.launchAtLogin)
+ Toggle("Auto-start server on launch", isOn: $settings.autoStartServer)
+ }
+
+ Section {
+ HStack {
+ Button("Reset to Defaults") {
+ settings.resetToDefaults()
+ }
+
+ Spacer()
+
+ Button("Done") {
+ dismiss()
+ }
+ .keyboardShortcut(.defaultAction)
+ }
+ }
+ }
+ .formStyle(.grouped)
+ .frame(width: 400, height: 310)
+ .fixedSize()
+ .onAppear {
+ NSApp.setActivationPolicy(.regular)
+ NSApp.activate(ignoringOtherApps: true)
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ if let window = NSApp.windows.first(where: { $0.title == "Settings" }) {
+ window.makeKeyAndOrderFront(nil)
+ }
+ }
+ }
+ .onDisappear {
+ if NSApp.windows.filter({ $0.isVisible && $0.title != "" }).isEmpty {
+ NSApp.setActivationPolicy(.accessory)
+ }
+ }
+ }
+}
diff --git a/Sources/AppleIntelligenceGRPC/Config.swift b/Sources/AppleIntelligenceCore/Config.swift
similarity index 77%
rename from Sources/AppleIntelligenceGRPC/Config.swift
rename to Sources/AppleIntelligenceCore/Config.swift
index 560ec5b..96242e2 100644
--- a/Sources/AppleIntelligenceGRPC/Config.swift
+++ b/Sources/AppleIntelligenceCore/Config.swift
@@ -1,25 +1,25 @@
import Foundation
/// Server configuration loaded from environment variables
-struct Config {
+public struct Config: Sendable {
/// Host to bind the server to (default: 0.0.0.0 for LAN access)
- let host: String
+ public let host: String
/// Port to listen on (default: 50051)
- let port: Int
+ public let port: Int
/// Optional API key for authentication via gRPC metadata
- let apiKey: String?
+ public let apiKey: String?
/// Initialize configuration from environment variables
- init() {
+ public init() {
self.host = ProcessInfo.processInfo.environment["GRPC_HOST"] ?? "0.0.0.0"
self.port = Int(ProcessInfo.processInfo.environment["GRPC_PORT"] ?? "50051") ?? 50051
self.apiKey = ProcessInfo.processInfo.environment["API_KEY"]
}
/// Initialize with explicit values (for testing)
- init(host: String, port: Int, apiKey: String? = nil) {
+ public init(host: String, port: Int, apiKey: String? = nil) {
self.host = host
self.port = port
self.apiKey = apiKey
diff --git a/Sources/AppleIntelligenceGRPC/Generated/AppleIntelligence.pb.swift b/Sources/AppleIntelligenceCore/Generated/AppleIntelligence.pb.swift
similarity index 100%
rename from Sources/AppleIntelligenceGRPC/Generated/AppleIntelligence.pb.swift
rename to Sources/AppleIntelligenceCore/Generated/AppleIntelligence.pb.swift
diff --git a/Sources/AppleIntelligenceGRPC/Providers/AppleIntelligenceProvider.swift b/Sources/AppleIntelligenceCore/Providers/AppleIntelligenceProvider.swift
similarity index 95%
rename from Sources/AppleIntelligenceGRPC/Providers/AppleIntelligenceProvider.swift
rename to Sources/AppleIntelligenceCore/Providers/AppleIntelligenceProvider.swift
index adb50b2..f0b888d 100644
--- a/Sources/AppleIntelligenceGRPC/Providers/AppleIntelligenceProvider.swift
+++ b/Sources/AppleIntelligenceCore/Providers/AppleIntelligenceProvider.swift
@@ -4,9 +4,9 @@ import GRPCProtobuf
import GRPCNIOTransportHTTP2
/// gRPC service provider for Apple Intelligence
-struct AppleIntelligenceProvider: RegistrableRPCService {
+public struct AppleIntelligenceProvider: RegistrableRPCService {
/// Service descriptor
- static let serviceDescriptor = ServiceDescriptor(
+ public static let serviceDescriptor = ServiceDescriptor(
fullyQualifiedService: "appleintelligence.AppleIntelligence"
)
@@ -32,12 +32,12 @@ struct AppleIntelligenceProvider: RegistrableRPCService {
/// Optional API key for authentication
private let apiKey: String?
- init(service: AppleIntelligenceService, apiKey: String? = nil) {
+ public init(service: AppleIntelligenceService, apiKey: String? = nil) {
self.service = service
self.apiKey = apiKey
}
- func registerMethods(with router: inout RPCRouter) {
+ public func registerMethods(with router: inout RPCRouter) {
// Register Complete method (unary)
router.registerHandler(
forMethod: Methods.complete,
diff --git a/Sources/AppleIntelligenceGRPC/Services/AppleIntelligenceService.swift b/Sources/AppleIntelligenceCore/Services/AppleIntelligenceService.swift
similarity index 87%
rename from Sources/AppleIntelligenceGRPC/Services/AppleIntelligenceService.swift
rename to Sources/AppleIntelligenceCore/Services/AppleIntelligenceService.swift
index 46186ad..95c2043 100644
--- a/Sources/AppleIntelligenceGRPC/Services/AppleIntelligenceService.swift
+++ b/Sources/AppleIntelligenceCore/Services/AppleIntelligenceService.swift
@@ -2,12 +2,12 @@ import Foundation
import FoundationModels
/// Errors that can occur when using Apple Intelligence
-enum AppleIntelligenceError: Error, CustomStringConvertible {
+public enum AppleIntelligenceError: Error, CustomStringConvertible, Sendable {
case modelNotAvailable
case generationFailed(String)
case sessionCreationFailed
- var description: String {
+ public var description: String {
switch self {
case .modelNotAvailable:
return "Apple Intelligence model is not available on this device"
@@ -20,15 +20,15 @@ enum AppleIntelligenceError: Error, CustomStringConvertible {
}
/// Service wrapper for Apple Intelligence Foundation Models
-actor AppleIntelligenceService {
+public actor AppleIntelligenceService {
/// The language model session
private var session: LanguageModelSession?
/// Whether the model is available
- private(set) var isAvailable: Bool = false
+ public private(set) var isAvailable: Bool = false
/// Initialize and check model availability
- init() async {
+ public init() async {
await checkAvailability()
}
@@ -47,7 +47,7 @@ actor AppleIntelligenceService {
}
/// Get the current model status as a string
- func getModelStatus() -> String {
+ public func getModelStatus() -> String {
let availability = SystemLanguageModel.default.availability
switch availability {
case .available:
@@ -60,7 +60,7 @@ actor AppleIntelligenceService {
}
/// Generate a completion for the given prompt (non-streaming)
- func complete(prompt: String, temperature: Float?, maxTokens: Int?) async throws -> String {
+ public func complete(prompt: String, temperature: Float?, maxTokens: Int?) async throws -> String {
guard isAvailable, let session = session else {
throw AppleIntelligenceError.modelNotAvailable
}
@@ -70,7 +70,7 @@ actor AppleIntelligenceService {
}
/// Generate a streaming completion for the given prompt
- func streamComplete(
+ public func streamComplete(
prompt: String,
temperature: Float?,
maxTokens: Int?
diff --git a/Sources/AppleIntelligenceGRPC/main.swift b/Sources/AppleIntelligenceServer/main.swift
similarity index 98%
rename from Sources/AppleIntelligenceGRPC/main.swift
rename to Sources/AppleIntelligenceServer/main.swift
index 0b5ae22..13980c9 100644
--- a/Sources/AppleIntelligenceGRPC/main.swift
+++ b/Sources/AppleIntelligenceServer/main.swift
@@ -2,6 +2,7 @@ import Foundation
import GRPCCore
import GRPCNIOTransportHTTP2
import ArgumentParser
+import AppleIntelligenceCore
@main
struct AppleIntelligenceServer: AsyncParsableCommand {
diff --git a/scripts/build-app.sh b/scripts/build-app.sh
new file mode 100755
index 0000000..2cb6ea9
--- /dev/null
+++ b/scripts/build-app.sh
@@ -0,0 +1,80 @@
+#!/bin/bash
+set -e
+
+# Configuration
+APP_NAME="Apple Intelligence Server"
+BUNDLE_ID="com.svrnty.apple-intelligence-server"
+VERSION="1.0.0"
+BUILD_NUMBER="1"
+MIN_OS_VERSION="26.0"
+
+# Paths
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
+BUILD_DIR="$PROJECT_DIR/.build/release"
+APP_BUNDLE="$PROJECT_DIR/dist/$APP_NAME.app"
+CONTENTS_DIR="$APP_BUNDLE/Contents"
+MACOS_DIR="$CONTENTS_DIR/MacOS"
+RESOURCES_DIR="$CONTENTS_DIR/Resources"
+
+echo "Building release binary..."
+cd "$PROJECT_DIR"
+swift build -c release --product AppleIntelligenceApp
+
+echo "Creating app bundle..."
+rm -rf "$APP_BUNDLE"
+mkdir -p "$MACOS_DIR"
+mkdir -p "$RESOURCES_DIR"
+
+echo "Copying executable..."
+cp "$BUILD_DIR/AppleIntelligenceApp" "$MACOS_DIR/$APP_NAME"
+
+echo "Creating Info.plist..."
+cat > "$CONTENTS_DIR/Info.plist" << EOF
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ $APP_NAME
+ CFBundleIconFile
+ AppIcon
+ CFBundleIdentifier
+ $BUNDLE_ID
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $APP_NAME
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $VERSION
+ CFBundleVersion
+ $BUILD_NUMBER
+ LSMinimumSystemVersion
+ $MIN_OS_VERSION
+ LSUIElement
+
+ NSHighResolutionCapable
+
+ NSLocalNetworkUsageDescription
+ Apple Intelligence Server needs local network access to accept connections from other devices on your network.
+ NSPrincipalClass
+ NSApplication
+
+
+EOF
+
+echo "Creating PkgInfo..."
+echo -n "APPL????" > "$CONTENTS_DIR/PkgInfo"
+
+echo ""
+echo "App bundle created at: $APP_BUNDLE"
+echo ""
+echo "Next steps for distribution:"
+echo "1. Add an app icon (AppIcon.icns) to $RESOURCES_DIR"
+echo "2. Code sign: codesign --deep --force --verify --verbose --sign \"Developer ID Application: YOUR NAME (TEAM_ID)\" \"$APP_BUNDLE\""
+echo "3. Notarize: xcrun notarytool submit \"$APP_BUNDLE\" --apple-id YOUR_APPLE_ID --password APP_SPECIFIC_PASSWORD --team-id TEAM_ID --wait"
+echo "4. Staple: xcrun stapler staple \"$APP_BUNDLE\""
diff --git a/scripts/create-dmg.sh b/scripts/create-dmg.sh
new file mode 100755
index 0000000..b470824
--- /dev/null
+++ b/scripts/create-dmg.sh
@@ -0,0 +1,60 @@
+#!/bin/bash
+set -e
+
+# Configuration
+APP_NAME="Apple Intelligence Server"
+DMG_NAME="AppleIntelligenceServer"
+VERSION="1.0.0"
+
+# Paths
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
+DIST_DIR="$PROJECT_DIR/dist"
+APP_BUNDLE="$DIST_DIR/$APP_NAME.app"
+DMG_PATH="$DIST_DIR/$DMG_NAME-$VERSION.dmg"
+TEMP_DMG="$DIST_DIR/temp.dmg"
+
+# Check if app bundle exists
+if [ ! -d "$APP_BUNDLE" ]; then
+ echo "App bundle not found. Running build-app.sh first..."
+ "$SCRIPT_DIR/build-app.sh"
+fi
+
+echo "Creating DMG..."
+
+# Remove old DMG if exists
+rm -f "$DMG_PATH"
+rm -f "$TEMP_DMG"
+
+# Create a temporary directory for DMG contents
+DMG_TEMP_DIR="$DIST_DIR/dmg-temp"
+rm -rf "$DMG_TEMP_DIR"
+mkdir -p "$DMG_TEMP_DIR"
+
+# Copy app to temp directory
+cp -R "$APP_BUNDLE" "$DMG_TEMP_DIR/"
+
+# Create symbolic link to Applications folder
+ln -s /Applications "$DMG_TEMP_DIR/Applications"
+
+# Create the DMG
+hdiutil create -volname "$APP_NAME" \
+ -srcfolder "$DMG_TEMP_DIR" \
+ -ov -format UDRW "$TEMP_DMG"
+
+# Convert to compressed DMG
+hdiutil convert "$TEMP_DMG" -format UDZO -o "$DMG_PATH"
+
+# Clean up
+rm -f "$TEMP_DMG"
+rm -rf "$DMG_TEMP_DIR"
+
+echo ""
+echo "DMG created: $DMG_PATH"
+echo ""
+echo "Size: $(du -h "$DMG_PATH" | cut -f1)"
+echo ""
+echo "To distribute:"
+echo "1. Code sign the app (requires Apple Developer account)"
+echo "2. Notarize the DMG (required for Gatekeeper)"
+echo "3. Share the DMG file"