mirror of
https://github.com/aculix/knotchy.git
synced 2026-10-27 07:15:53 +00:00
Initial release
This commit is contained in:
commit
4b67728cce
108 files changed
+13310
No files matched your search
@@ -0,0 +1,291 @@
|
||||
import KnotchyCore
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum SettingsTab: String, CaseIterable, Identifiable {
|
||||
case general, network, vm
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .general: "General"
|
||||
case .network: "Network"
|
||||
case .vm: "VM"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .general: "gearshape"
|
||||
case .network: "network"
|
||||
case .vm: "shippingbox"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SettingsNavigation {
|
||||
var section: SettingsTab? = .general
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class SettingsWindowController {
|
||||
static let size = CGSize(width: 700, height: 470)
|
||||
|
||||
private let app: AppDelegate
|
||||
private let navigation = SettingsNavigation()
|
||||
private(set) var window: NSWindow?
|
||||
|
||||
init(app: AppDelegate) {
|
||||
self.app = app
|
||||
}
|
||||
|
||||
func show(_ section: SettingsTab? = nil) {
|
||||
if let section { navigation.section = section }
|
||||
let window = self.window ?? makeWindow()
|
||||
self.window = window
|
||||
// An accessory app's windows open behind the frontmost app unless it activates first.
|
||||
NSApp.activate()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
private func makeWindow() -> NSWindow {
|
||||
let host = NSHostingController(rootView: SettingsView(app: app, settings: app.settings, navigation: navigation))
|
||||
host.sceneBridgingOptions = [.title, .toolbars]
|
||||
let window = NSWindow(contentViewController: host)
|
||||
window.styleMask = [.titled, .closable, .miniaturizable, .fullSizeContentView]
|
||||
window.toolbarStyle = .unified
|
||||
window.title = "Knotchy Settings"
|
||||
window.isReleasedWhenClosed = false
|
||||
window.setContentSize(Self.size)
|
||||
window.center()
|
||||
window.setFrameAutosaveName("KnotchySettings")
|
||||
return window
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsView: View {
|
||||
let app: AppDelegate
|
||||
@Bindable var settings: AppSettings
|
||||
@Bindable var navigation: SettingsNavigation
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List(SettingsTab.allCases, selection: $navigation.section) { section in
|
||||
Label(section.title, systemImage: section.symbol)
|
||||
}
|
||||
.navigationSplitViewColumnWidth(180)
|
||||
.toolbar(removing: .sidebarToggle)
|
||||
} detail: {
|
||||
Group {
|
||||
switch navigation.section ?? .general {
|
||||
case .general: GeneralSettings(settings: settings)
|
||||
case .network: NetworkSettings(settings: settings)
|
||||
case .vm: VMSettings(controller: app.controller, status: app.vmStatus)
|
||||
}
|
||||
}
|
||||
.navigationTitle((navigation.section ?? .general).title)
|
||||
}
|
||||
.frame(width: SettingsWindowController.size.width, height: SettingsWindowController.size.height)
|
||||
}
|
||||
}
|
||||
|
||||
private struct GeneralSettings: View {
|
||||
@Bindable var settings: AppSettings
|
||||
@State private var opensAtLogin = LoginItem.isEnabled
|
||||
@State private var loginError: String?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Picker("Destroy folded pages", selection: $settings.autoDestroyMinutes) {
|
||||
ForEach(AppSettings.autoDestroyChoices, id: \.self) { minutes in
|
||||
Text(minutes == 1 ? "After 1 minute" : "After \(minutes) minutes").tag(minutes)
|
||||
}
|
||||
}
|
||||
Picker("Open pages in", selection: $settings.opensLarge) {
|
||||
Text("Small panel").tag(false)
|
||||
Text("Large panel").tag(true)
|
||||
}
|
||||
} footer: {
|
||||
Text("A page folded into the notch keeps running until it's destroyed.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Open copied link with \(GlobalHotKey.openCopiedLinkLabel)", isOn: $settings.copiedLinkShortcut)
|
||||
} footer: {
|
||||
Text("Copy a link anywhere (in Chrome: right-click › Copy Link Address), then press \(GlobalHotKey.openCopiedLinkLabel).")
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Open Knotchy at login", isOn: Binding {
|
||||
opensAtLogin
|
||||
} set: { enabled in
|
||||
loginError = LoginItem.set(enabled)
|
||||
opensAtLogin = LoginItem.isEnabled
|
||||
})
|
||||
} footer: {
|
||||
if let loginError { Text(loginError).foregroundStyle(.red) }
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.scrollDisabled(true)
|
||||
.onAppear { opensAtLogin = LoginItem.isEnabled }
|
||||
}
|
||||
}
|
||||
|
||||
// Every mode's form stays laid out, inactive ones hidden, so switching crossfades instead of resizing the window.
|
||||
private struct NetworkSettings: View {
|
||||
@Bindable var settings: AppSettings
|
||||
@State private var importing = false
|
||||
@State private var importMessage: String?
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
ForEach([AppSettings.NetworkMode.direct, .proxy, .vpn], id: \.self) { mode in
|
||||
let active = settings.networkMode == mode
|
||||
NetworkForm(settings: settings, mode: mode, importMessage: importMessage, importing: $importing)
|
||||
.opacity(active ? 1 : 0)
|
||||
.allowsHitTesting(active)
|
||||
.accessibilityHidden(!active)
|
||||
.animation(active ? .easeOut(duration: 0.2).delay(0.05) : .easeOut(duration: 0.1), value: active)
|
||||
}
|
||||
}
|
||||
.fileImporter(isPresented: $importing,
|
||||
allowedContentTypes: [UTType(filenameExtension: "conf") ?? .data, .zip],
|
||||
allowsMultipleSelection: true) { result in
|
||||
if case .success(let urls) = result {
|
||||
Task { importMessage = await settings.importWireGuard(from: urls) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NetworkForm: View {
|
||||
@Bindable var settings: AppSettings
|
||||
let mode: AppSettings.NetworkMode
|
||||
let importMessage: String?
|
||||
@Binding var importing: Bool
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Picker("Connection", selection: $settings.networkMode) {
|
||||
Text("Direct").tag(AppSettings.NetworkMode.direct)
|
||||
Text("Proxy").tag(AppSettings.NetworkMode.proxy)
|
||||
Text("WireGuard VPN").tag(AppSettings.NetworkMode.vpn)
|
||||
}
|
||||
.pickerStyle(.radioGroup)
|
||||
} footer: {
|
||||
Text(description)
|
||||
}
|
||||
|
||||
details
|
||||
.disabled(settings.networkMode != mode) // No tabbing into a hidden form.
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.scrollDisabled(true)
|
||||
}
|
||||
|
||||
private var description: String {
|
||||
switch mode {
|
||||
case .direct: "Pages go out through this Mac's internet connection, and can reach nothing else."
|
||||
case .proxy: "Pages can only go out through the proxy. If it's down, they don't load."
|
||||
case .vpn: "Pages can only go out through the VPN. If it's down, they don't load. The rest of your Mac isn't affected."
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var details: some View {
|
||||
switch mode {
|
||||
case .direct:
|
||||
Section {
|
||||
LabeledContent("Internet", value: "This Mac's connection")
|
||||
LabeledContent("Blocked", value: "Your Mac, your network, IPv6")
|
||||
LabeledContent("DNS", value: "1.1.1.1 (Cloudflare)")
|
||||
} header: {
|
||||
Text("Direct")
|
||||
}
|
||||
case .proxy:
|
||||
Section {
|
||||
Picker("Type", selection: $settings.proxyKind) {
|
||||
Text("HTTP").tag(ProxySettings.Kind.http)
|
||||
Text("SOCKS5").tag(ProxySettings.Kind.socks5)
|
||||
}
|
||||
TextField("Server", text: $settings.proxyHost, prompt: Text("proxy.example.com"))
|
||||
TextField("Port", value: $settings.proxyPort, format: .number.grouping(.never), prompt: Text("8080"))
|
||||
TextField("Username", text: $settings.proxyUsername, prompt: Text("Optional"))
|
||||
SecureField("Password", text: $settings.proxyPassword, prompt: Text("Optional"))
|
||||
} header: {
|
||||
Text("Proxy")
|
||||
} footer: {
|
||||
if settings.proxyIsLocal {
|
||||
Text("Proxies on this Mac or your network aren't allowed: pages could reach your network through them.")
|
||||
.foregroundStyle(.orange)
|
||||
} else {
|
||||
Text("The password is kept in your keychain.")
|
||||
}
|
||||
}
|
||||
case .vpn:
|
||||
Section {
|
||||
if settings.wireGuardNames.isEmpty {
|
||||
LabeledContent("Server", value: "None")
|
||||
LabeledContent("Endpoint", value: "—")
|
||||
LabeledContent("DNS", value: "—")
|
||||
LabeledContent("No configs imported") {
|
||||
Button("Import…") { importing = true }
|
||||
}
|
||||
} else {
|
||||
Picker("Server", selection: $settings.wireGuardServer) {
|
||||
ForEach(settings.wireGuardNames, id: \.self) { Text($0).tag($0) }
|
||||
}
|
||||
let config = settings.selectedWireGuard
|
||||
LabeledContent("Endpoint", value: config.map { "\($0.endpointHost):\(String($0.endpointPort))" } ?? "—")
|
||||
LabeledContent("DNS", value: config.map { $0.dns.isEmpty ? "1.1.1.1" : $0.dns.joined(separator: ", ") } ?? "—")
|
||||
LabeledContent(settings.wireGuardNames.count == 1 ? "1 config" : "\(settings.wireGuardNames.count) configs") {
|
||||
HStack {
|
||||
Button("Remove All") { settings.removeWireGuardConfigs() }
|
||||
Button("Import…") { importing = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("WireGuard")
|
||||
} footer: {
|
||||
if let importMessage {
|
||||
Text(importMessage).foregroundStyle(.orange).lineLimit(2)
|
||||
} else {
|
||||
Text("Import the .conf files, or the .zip, from Mullvad, Proton VPN, IVPN or others.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct VMSettings: View {
|
||||
let controller: SessionController
|
||||
let status: VMStatus
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
LabeledContent("Container runtime", value: status.runtimeSummary)
|
||||
LabeledContent("VM image", value: status.summary)
|
||||
LabeledContent("Rebuild") {
|
||||
Button(status.image == .missing ? "Build Now" : "Rebuild Now") { controller.rebuildImage() }
|
||||
.disabled(isSettingUp || status.image == .noContainerTool)
|
||||
}
|
||||
} footer: {
|
||||
Text("Rebuilding takes about 3 minutes and picks up the latest WebKit security fixes. Progress shows in the notch.")
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.scrollDisabled(true)
|
||||
.onAppear { status.refresh() }
|
||||
}
|
||||
|
||||
private var isSettingUp: Bool {
|
||||
if case .settingUp = controller.state { true } else { false }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user