Init commit
@@ -0,0 +1,80 @@
|
||||
import ApplicationLibrary
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct Application: App {
|
||||
@NSApplicationDelegateAdaptor private var appDelegate: ApplicationDelegate
|
||||
|
||||
@State private var showMenuBarExtra = false
|
||||
@State private var isMenuPresented = false
|
||||
|
||||
var body: some Scene {
|
||||
Window("sing-box", id: "main", content: {
|
||||
MainView()
|
||||
.onAppear {
|
||||
Task.detached {
|
||||
await initialize()
|
||||
}
|
||||
}
|
||||
.environment(\.showMenuBarExtra, $showMenuBarExtra)
|
||||
})
|
||||
.commands {
|
||||
if showMenuBarExtra {
|
||||
CommandGroup(replacing: .appTermination) {
|
||||
Button("Quit sing-box") {
|
||||
hide(closeApp: true)
|
||||
}
|
||||
.keyboardShortcut("q", modifiers: [.command])
|
||||
}
|
||||
CommandGroup(replacing: .saveItem) {
|
||||
Button("Close") {
|
||||
hide(closeApp: false)
|
||||
}
|
||||
.keyboardShortcut("w", modifiers: [.command])
|
||||
}
|
||||
}
|
||||
SidebarCommands()
|
||||
}
|
||||
|
||||
Window("New Profile", id: NewProfileView.windowID) {
|
||||
NewProfileView()
|
||||
}
|
||||
|
||||
WindowGroup("Edit Profile", id: EditProfileWindowView.windowID, for: Int64.self) { profileID in
|
||||
EditProfileWindowView(profileID.wrappedValue)
|
||||
}.commandsRemoved()
|
||||
|
||||
WindowGroup("Edit Content", id: EditProfileContentView.windowID, for: EditProfileContentView.Context.self) { context in
|
||||
EditProfileContentView(context.wrappedValue)
|
||||
}.commandsRemoved()
|
||||
|
||||
Window("Service Log", id: ServiceLogView.windowID) {
|
||||
ServiceLogView()
|
||||
}
|
||||
MenuBarExtra(isInserted: $showMenuBarExtra) {
|
||||
MenuView(isMenuPresented: $isMenuPresented)
|
||||
} label: {
|
||||
Image(systemName: "network.badge.shield.half.filled")
|
||||
}
|
||||
.menuBarExtraStyle(.window)
|
||||
.menuBarExtraAccess(isPresented: $isMenuPresented)
|
||||
}
|
||||
|
||||
private func initialize() async {
|
||||
let initialShowMenuBarExtra = SharedPreferences.showMenuBarExtra
|
||||
await MainActor.run {
|
||||
showMenuBarExtra = initialShowMenuBarExtra
|
||||
}
|
||||
}
|
||||
|
||||
private func hide(closeApp: Bool) {
|
||||
if closeApp || NSApp.keyWindow?.identifier?.rawValue == "main" {
|
||||
let transformState = ProcessApplicationTransformState(kProcessTransformToUIElementApplication)
|
||||
var psn = ProcessSerialNumber(highLongOfPSN: 0, lowLongOfPSN: UInt32(kCurrentProcess))
|
||||
TransformProcessType(&psn, transformState)
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
}
|
||||
NSApp.keyWindow?.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import AppKit
|
||||
import ApplicationLibrary
|
||||
import Foundation
|
||||
import Libbox
|
||||
import Library
|
||||
|
||||
class ApplicationDelegate: NSObject, NSApplicationDelegate {
|
||||
func applicationDidFinishLaunching(_: Notification) {
|
||||
NSLog("Here I stand")
|
||||
// ServiceNotification.register() // Not work
|
||||
let event = NSAppleEventManager.shared().currentAppleEvent
|
||||
let launchedAsLogInItem =
|
||||
event?.eventID == kAEOpenApplication &&
|
||||
event?.paramDescriptor(forKeyword: keyAEPropData)?.enumCodeValue == keyAELaunchedAsLogInItem
|
||||
if !launchedAsLogInItem || !SharedPreferences.showMenuBarExtra {
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
} else {
|
||||
NSApp.windows.first?.close()
|
||||
}
|
||||
Task.detached {
|
||||
do {
|
||||
try await self.postStart(launchedAsLogInItem)
|
||||
} catch {
|
||||
NSLog("application setup error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func postStart(_ launchedAsLogInItem: Bool) async throws {
|
||||
try ProfileUpdateTask.setup()
|
||||
if launchedAsLogInItem {
|
||||
if SharedPreferences.startedByUser {
|
||||
if let profile = try await ExtensionProfile.load() {
|
||||
try await profile.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool {
|
||||
!SharedPreferences.showMenuBarExtra
|
||||
}
|
||||
|
||||
func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
||||
if !flag, NSApp.activationPolicy() == .accessory {
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors": [
|
||||
{
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "apple-16 1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "apple-16 2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "apple-32 1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "apple-32 2x 1.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "apple-128 1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "apple-128 2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "apple-256 1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "apple-256 2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "apple-512 1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "apple-512 2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 340 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>NSUbiquitousContainers</key>
|
||||
<dict>
|
||||
<key>iCloud.io.nekohasekai.sfa</key>
|
||||
<dict>
|
||||
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
|
||||
<true/>
|
||||
<key>NSUbiquitousContainerName</key>
|
||||
<string>sing-box</string>
|
||||
<key>NSUbiquitousContainerSupportedFolderLevels</key>
|
||||
<string>Any</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,101 @@
|
||||
import ApplicationLibrary
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
struct MainView: View {
|
||||
@Environment(\.controlActiveState) private var controlActiveState
|
||||
|
||||
@State private var selection = NavigationPage.dashboard
|
||||
@State private var extensionProfile: ExtensionProfile?
|
||||
@State private var profileLoading = true
|
||||
@State private var logClient: LogClient!
|
||||
|
||||
@State private var serviceNotificationTitle = ""
|
||||
@State private var serviceNotificationContent = ""
|
||||
@State private var serviceNotificationPresented = false
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
VStack {
|
||||
SidebarView()
|
||||
}
|
||||
.frame(minWidth: 150)
|
||||
} detail: {
|
||||
if profileLoading {
|
||||
ProgressView().onAppear {
|
||||
Task {
|
||||
logClient = LogClient(SharedPreferences.maxLogLines)
|
||||
await loadProfile()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
selection.contentView
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $serviceNotificationPresented, content: {
|
||||
Alert(
|
||||
title: Text(serviceNotificationTitle),
|
||||
message: Text(serviceNotificationContent),
|
||||
dismissButton: .default(Text("Ok"))
|
||||
)
|
||||
})
|
||||
.onAppear {
|
||||
ServiceNotification.setServiceNotificationListener { notification in
|
||||
serviceNotificationTitle = notification.title
|
||||
serviceNotificationContent = notification.body
|
||||
serviceNotificationPresented = true
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
ServiceNotification.removeServiceNotificationListener()
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigation) {
|
||||
StartStopButton()
|
||||
}
|
||||
}
|
||||
.onChange(of: controlActiveState, perform: { newValue in
|
||||
if newValue != .inactive {
|
||||
Task {
|
||||
await loadProfile()
|
||||
connectLog()
|
||||
}
|
||||
}
|
||||
})
|
||||
.onChange(of: selection, perform: { value in
|
||||
if value == .logs {
|
||||
connectLog()
|
||||
}
|
||||
})
|
||||
.formStyle(.grouped)
|
||||
.environment(\.selection, $selection)
|
||||
.environment(\.extensionProfile, $extensionProfile)
|
||||
.environment(\.logClient, $logClient)
|
||||
}
|
||||
|
||||
private func loadProfile() async {
|
||||
defer {
|
||||
profileLoading = false
|
||||
}
|
||||
if let newProfile = try? await ExtensionProfile.load() {
|
||||
if extensionProfile == nil {
|
||||
newProfile.register()
|
||||
extensionProfile = newProfile
|
||||
}
|
||||
} else {
|
||||
extensionProfile = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func connectLog() {
|
||||
guard let profile = extensionProfile else {
|
||||
return
|
||||
}
|
||||
guard let logClient else {
|
||||
return
|
||||
}
|
||||
if profile.status.isConnected, !logClient.isConnected {
|
||||
logClient.reconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import ApplicationLibrary
|
||||
import Foundation
|
||||
import Libbox
|
||||
import Library
|
||||
import MacControlCenterUI
|
||||
import MenuBarExtraAccess
|
||||
import SwiftUI
|
||||
|
||||
struct MenuView: View {
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
|
||||
private static let sliderWidth: CGFloat = 270
|
||||
|
||||
@Binding var isMenuPresented: Bool
|
||||
|
||||
@State private var isLoading = true
|
||||
@State private var profile: ExtensionProfile?
|
||||
|
||||
var body: some View {
|
||||
MacControlCenterMenu(isPresented: $isMenuPresented) {
|
||||
MenuHeader("sing-box") {
|
||||
if isLoading {
|
||||
Text("Loading...").foregroundColor(.secondary).onAppear {
|
||||
Task.detached {
|
||||
await loadProfile()
|
||||
}
|
||||
}
|
||||
} else if let profile {
|
||||
Text(LibboxVersion()).foregroundColor(.secondary)
|
||||
StatusSwitch(profile)
|
||||
} else {
|
||||
Text("NetworkExtension not installed")
|
||||
}
|
||||
}
|
||||
.frame(minWidth: MenuView.sliderWidth)
|
||||
if let profile {
|
||||
ProfilePicker(profile)
|
||||
}
|
||||
Divider()
|
||||
MenuCommand {
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
openWindow(id: "main")
|
||||
} label: {
|
||||
Text("Open")
|
||||
}
|
||||
MenuCommand {
|
||||
NSApp.terminate(nil)
|
||||
} label: {
|
||||
Text("Exit")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadProfile() async {
|
||||
profile = try? await ExtensionProfile.load()
|
||||
if let profile {
|
||||
profile.register()
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private struct StatusSwitch: View {
|
||||
@ObservedObject private var profile: ExtensionProfile
|
||||
@State private var errorPresented = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
init(_ profile: ExtensionProfile) {
|
||||
self.profile = profile
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Toggle(isOn: Binding(get: {
|
||||
profile.status.isConnected
|
||||
}, set: { _ in
|
||||
Task.detached {
|
||||
await switchProfile(!profile.status.isConnected)
|
||||
}
|
||||
})) {}
|
||||
.toggleStyle(.switch)
|
||||
.disabled(!profile.status.isEnabled)
|
||||
.alert(isPresented: $errorPresented) {
|
||||
Alert(
|
||||
title: Text("Error"),
|
||||
message: Text(errorMessage),
|
||||
dismissButton: .default(Text("Ok"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func switchProfile(_ isEnabled: Bool) async {
|
||||
do {
|
||||
if isEnabled {
|
||||
try await profile.start()
|
||||
} else {
|
||||
profile.stop()
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProfilePicker: View {
|
||||
@ObservedObject private var profile: ExtensionProfile
|
||||
|
||||
init(_ profile: ExtensionProfile) {
|
||||
self.profile = profile
|
||||
}
|
||||
|
||||
@State private var isLoading = true
|
||||
@State private var profileList: [Profile] = []
|
||||
@State private var selectedProfileID: Int64!
|
||||
@State private var reasserting = false
|
||||
|
||||
@State private var errorPresented = false
|
||||
@State private var errorMessage = ""
|
||||
@State private var observer: Any?
|
||||
|
||||
var body: some View {
|
||||
viewBuilder {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task.detached {
|
||||
await doReload()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if profileList.isEmpty {
|
||||
Text("Empty profiles")
|
||||
} else {
|
||||
MenuSection("Profile")
|
||||
Picker("", selection: $selectedProfileID) {
|
||||
ForEach(profileList, id: \.id) { profile in
|
||||
Text(profile.name)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
.onChange(of: selectedProfileID) { _ in
|
||||
reasserting = true
|
||||
Task.detached {
|
||||
await switchProfile(selectedProfileID!)
|
||||
}
|
||||
}
|
||||
.disabled(!profile.status.isSwitchable || reasserting)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if observer == nil {
|
||||
observer = NotificationCenter.default.addObserver(forName: ActiveDashboardView.NotificationUpdateSelectedProfile, object: nil, queue: nil, using: { _ in
|
||||
doReload()
|
||||
})
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if let observer {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $errorPresented) {
|
||||
Alert(
|
||||
title: Text("Error"),
|
||||
message: Text(errorMessage),
|
||||
dismissButton: .default(Text("Ok"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func doReload() {
|
||||
defer {
|
||||
isLoading = false
|
||||
}
|
||||
do {
|
||||
profileList = try ProfileManager.list()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
if profileList.isEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
selectedProfileID = SharedPreferences.selectedProfileID
|
||||
if profileList.filter({ profile in
|
||||
profile.id == selectedProfileID
|
||||
})
|
||||
.isEmpty {
|
||||
selectedProfileID = profileList[0].id!
|
||||
SharedPreferences.selectedProfileID = selectedProfileID
|
||||
}
|
||||
}
|
||||
|
||||
private func switchProfile(_ newProfileID: Int64) {
|
||||
SharedPreferences.selectedProfileID = newProfileID
|
||||
NotificationCenter.default.post(name: ActiveDashboardView.NotificationUpdateSelectedProfile, object: nil)
|
||||
if profile.status.isConnected {
|
||||
do {
|
||||
try LibboxNewStandaloneCommandClient(FilePath.sharedDirectory.relativePath)?.serviceReload()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
}
|
||||
}
|
||||
reasserting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.io.nekohasekai.sfa</string>
|
||||
</array>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudDocuments</string>
|
||||
</array>
|
||||
<key>com.apple.developer.networking.networkextension</key>
|
||||
<array>
|
||||
<string>packet-tunnel-provider</string>
|
||||
</array>
|
||||
<key>com.apple.developer.ubiquity-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.io.nekohasekai.sfa</string>
|
||||
</array>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.io.nekohasekai.sfa</string>
|
||||
</array>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,47 @@
|
||||
import ApplicationLibrary
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
struct SidebarView: View {
|
||||
@Environment(\.selection) private var selection
|
||||
@Environment(\.extensionProfile) private var extensionProfile
|
||||
|
||||
var body: some View {
|
||||
viewBuilder {
|
||||
if let profile = extensionProfile.wrappedValue {
|
||||
SidebarView0().environmentObject(profile)
|
||||
} else {
|
||||
SidebarView1()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SidebarView0: View {
|
||||
@Environment(\.selection) private var selection
|
||||
@EnvironmentObject private var extensionProfile: ExtensionProfile
|
||||
|
||||
var body: some View {
|
||||
List(NavigationPage.allCases.filter { it in
|
||||
it.visible(extensionProfile)
|
||||
}, selection: selection) { it in
|
||||
it.label
|
||||
}.onChange(of: extensionProfile.status) { _ in
|
||||
if !selection.wrappedValue.visible(extensionProfile) {
|
||||
selection.wrappedValue = NavigationPage.dashboard
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SidebarView1: View {
|
||||
@Environment(\.selection) private var selection
|
||||
|
||||
var body: some View {
|
||||
List(NavigationPage.allCases.filter { it in
|
||||
it.visible(nil)
|
||||
}, selection: selection) { it in
|
||||
it.label
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||