diff --git a/.gitignore b/.gitignore index 917857e..448d046 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,14 @@ libgost/*.h libgost/*.a libgost/*.aar libgost/*.jar +libgost/*.xcframework libgost/build/ # 个人配置(含敏感信息,不提交) android/config-templates/*.yaml +macos/Configuration/*.xcconfig +!macos/Configuration/*.example.xcconfig android/app/libs/libgost*.aar +.pi-subagents/ +macos/Frameworks/ +macos/build/ diff --git a/Makefile b/Makefile index f6da135..0248c8c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all android android-release clean +.PHONY: all android android-release macos macos-release clean all: android @@ -8,10 +8,56 @@ android: libgost/libgost.aar android-release: libgost/libgost.aar cd android && ./gradlew assembleRelease bundleRelease +macos: macos/Frameworks/Libgost.xcframework + cd macos && xcodebuild -project GostX.xcodeproj -scheme GostX -configuration Release -derivedDataPath build ONLY_ACTIVE_ARCH=YES build + +# Developer ID distribution: signed, notarized DMG for direct sharing +# Prerequisites: +# 1. Developer ID Application certificate in Keychain +# 2. (Optional) APPLE_ID, APPLE_PASSWORD, APPLE_TEAM env vars for notarization +# APPLE_PASSWORD should be an app-specific password from appleid.apple.com +macos-release: macos/Frameworks/Libgost.xcframework + cd macos && \ + xcodebuild archive \ + -project GostX.xcodeproj \ + -scheme GostX \ + -configuration Release \ + -archivePath build/GostX.xcarchive \ + -destination 'platform=macOS' && \ + xcodebuild -exportArchive \ + -archivePath build/GostX.xcarchive \ + -exportOptionsPlist Configuration/ExportOptions.plist \ + -exportPath build/Release && \ + hdiutil create -volname GostX \ + -srcfolder build/Release/GostX.app \ + -ov -format UDZO \ + build/GostX.dmg + @echo "✅ DMG ready: macos/build/GostX.dmg" + @if [ -n "$$APPLE_ID" ] && [ -n "$$APPLE_PASSWORD" ] && [ -n "$$APPLE_TEAM" ]; then \ + echo "📤 Submitting for notarization..."; \ + xcrun notarytool submit macos/build/GostX.dmg \ + --apple-id "$$APPLE_ID" \ + --password "$$APPLE_PASSWORD" \ + --team-id "$$APPLE_TEAM" \ + --wait && \ + xcrun stapler staple macos/build/GostX.dmg && \ + echo "✅ Notarized: macos/build/GostX.dmg"; \ + else \ + echo "⚠️ Skipping notarization (set APPLE_ID, APPLE_PASSWORD, APPLE_TEAM to enable)"; \ + fi + libgost/libgost.aar: cd libgost && $(MAKE) libgost.aar -.PHONY: clean +macos/Frameworks/Libgost.xcframework: + cd libgost && $(MAKE) macos-xcframework + mkdir -p macos/Frameworks + rm -rf macos/Frameworks/Libgost.xcframework + cp -R libgost/Libgost.xcframework macos/Frameworks/Libgost.xcframework + @echo "Libgost.xcframework → macos/Frameworks/" + clean: cd libgost && $(MAKE) clean cd android && ./gradlew clean 2>/dev/null || true + rm -rf macos/Frameworks/Libgost.xcframework + rm -rf macos/build diff --git a/docs/superpowers/plans/2025-07-16-macos-settings-navigationsplitview.md b/docs/superpowers/plans/2025-07-16-macos-settings-navigationsplitview.md new file mode 100644 index 0000000..de1d746 --- /dev/null +++ b/docs/superpowers/plans/2025-07-16-macos-settings-navigationsplitview.md @@ -0,0 +1,1218 @@ +# macOS Settings NavigationSplitView Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the custom tab-bar layout in `SettingsView` with a macOS-standard `NavigationSplitView` three-column layout (Sidebar → Content → Detail). + +**Architecture:** Extract the three tab contents into list/detail view pairs, lift shared state into `SettingsView`, and wire it through a `NavigationSplitView`. Delete `LogView.swift` and `FileManageView.swift` after extraction. + +**Tech Stack:** SwiftUI, macOS 14.0+, Combine + +## Global Constraints + +- macOS 14.0 minimum deployment target +- Always show 3 columns; sidebar hides via system toggle on narrow windows +- Follow existing patterns: `@StateObject` + `@ObservedObject` for view models, `@Published` for observable state +- Existing `YamlEditorView` (defined in SettingsView.swift) stays untouched +- New files registered in Xcode project via `project.pbxproj` + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|----------------| +| `macos/GostX/LogViewModel.swift` | Create | ObservableObject: log lines, polling, copy, clear | +| `macos/GostX/LogOptionsView.swift` | Create | Logging toggle + level picker | +| `macos/GostX/LogContentView.swift` | Create | Scrollable log lines + toolbar (pause, copy, clear) | +| `macos/GostX/ProfileListView.swift` | Create | Profile list with add/rename/delete | +| `macos/GostX/FileListView.swift` | Create | File list with import/new/rename/delete | +| `macos/GostX/FileContentView.swift` | Create | TextEditor for file content + save | +| `macos/GostX/SettingsView.swift` | Modify | Rewrite as NavigationSplitView orchestrator | +| `macos/GostX/LogView.swift` | Delete | Gutted into LogOptionsView + LogContentView | +| `macos/GostX/FileManageView.swift` | Delete | Gutted into FileListView + FileContentView | +| `macos/GostXTests/GostXTests.swift` | Modify | Add LogViewModel tests | +| `macos/GostX.xcodeproj/project.pbxproj` | Modify | Add/remove file references | + +--- + +### Task 1: Create LogViewModel + +**Files:** +- Create: `macos/GostX/LogViewModel.swift` +- Modify: `macos/GostXTests/GostXTests.swift` + +**Interfaces:** +- Produces: `class LogViewModel: ObservableObject` with `@Published var lines: [String]`, `@Published var isFollowing: Bool`, `func onAppear()`, `func onDisappear()`, `func copyAll()`, `func clearLog()` + +- [ ] **Step 1: Write LogViewModel** + +Create `macos/GostX/LogViewModel.swift`: + +```swift +// macos/GostX/LogViewModel.swift +import SwiftUI +import Combine + +@MainActor +class LogViewModel: ObservableObject { + @Published var lines: [String] = [] + @Published var isFollowing = true + + var scrollProxy: ScrollViewProxy? + private var timer: Timer? + + private var logFileURL: URL? { + AppGroupConfig.containerURL?.appendingPathComponent("gost.log") + } + + func onAppear() { + loadLog() + startPolling() + } + + func onDisappear() { + stopPolling() + } + + func copyAll() { + let text = lines.joined(separator: "\n") + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + + func clearLog() { + guard let url = logFileURL else { return } + try? "".write(to: url, atomically: true, encoding: .utf8) + lines = [] + } + + // MARK: - Private + + private func loadLog() { + guard let url = logFileURL, + FileManager.default.fileExists(atPath: url.path), + let content = try? String(contentsOf: url, encoding: .utf8) + else { + lines = [] + return + } + lines = content.components(separatedBy: "\n").filter { !$0.isEmpty } + } + + private func startPolling() { + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + guard let self else { return } + DispatchQueue.main.async { + let oldCount = self.lines.count + self.loadLog() + if self.isFollowing, self.lines.count > oldCount, let proxy = self.scrollProxy { + proxy.scrollTo(self.lines.count - 1, anchor: .bottom) + } + } + } + } + + private func stopPolling() { + timer?.invalidate() + timer = nil + } +} +``` + +- [ ] **Step 2: Write LogViewModel tests** + +Add to `macos/GostXTests/GostXTests.swift` after the existing test class: + +```swift +@MainActor +class LogViewModelTests: XCTestCase { + var vm: LogViewModel! + var tempDir: URL! + + override func setUpWithError() throws { + tempDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + // Write a known log file + let log = tempDir.appendingPathComponent("gost.log") + try "line one\nline two\nline three\n".write(to: log, atomically: true, encoding: .utf8) + // Override containerURL to return our temp dir + // We test load/clear/copy independently by writing to a temp file + vm = LogViewModel() + } + + override func tearDownWithError() throws { + vm.onDisappear() + try? FileManager.default.removeItem(at: tempDir) + vm = nil + } + + func testInitialState() { + XCTAssertTrue(vm.isFollowing) + // lines depend on file existence; without a real log file they start empty + } + + func testClearLog() { + vm.clearLog() + // After clear, lines should be empty + XCTAssertEqual(vm.lines.count, 0) + } + + func testCopyAll() { + // lines is empty by default in unit test env (no App Group file) + vm.copyAll() + // Pasteboard should contain empty string + XCTAssertEqual(NSPasteboard.general.string(forType: .string), "") + } + + func testIsFollowingToggle() { + vm.isFollowing = false + XCTAssertFalse(vm.isFollowing) + vm.isFollowing = true + XCTAssertTrue(vm.isFollowing) + } +} +``` + +- [ ] **Step 3: Run tests** + +Run: `cd macos && xcodebuild test -project GostX.xcodeproj -scheme GostX -destination 'platform=macOS' ONLY_ACTIVE_ARCH=YES 2>&1 | tail -20` +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add macos/GostX/LogViewModel.swift macos/GostXTests/GostXTests.swift +git commit -m "feat: add LogViewModel with polling, copy, and clear" +``` + +--- + +### Task 2: Create LogOptionsView + +**Files:** +- Create: `macos/GostX/LogOptionsView.swift` + +**Interfaces:** +- Consumes: `AppGroupConfig.loggingEnabled` (Bool), `AppGroupConfig.logLevel` (String), `AppGroupConfig.logLevelOptions` ([String]) +- Produces: `struct LogOptionsView: View` — toggle + picker + hint text + +- [ ] **Step 1: Write LogOptionsView** + +Create `macos/GostX/LogOptionsView.swift`: + +```swift +// macos/GostX/LogOptionsView.swift +import SwiftUI + +@available(macOS 14.0, *) +struct LogOptionsView: View { + @State private var loggingEnabled = AppGroupConfig.loggingEnabled + @State private var logLevel = AppGroupConfig.logLevel + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Toggle(isOn: $loggingEnabled) { + Text(NSLocalizedString("Logging", comment: "")) + .font(.system(size: 12, weight: .medium)) + } + .toggleStyle(.switch) + .controlSize(.small) + .onChange(of: loggingEnabled) { newValue in + AppGroupConfig.loggingEnabled = newValue + AppLogger.log(.info, "Logging \(newValue ? "enabled" : "disabled")") + } + } + + if loggingEnabled { + VStack(alignment: .leading, spacing: 6) { + Text(NSLocalizedString("Log Level", comment: "")) + .font(.system(size: 11)) + .foregroundColor(.secondary) + + Picker("", selection: $logLevel) { + ForEach(AppGroupConfig.logLevelOptions, id: \.self) { level in + Text(level.capitalized).tag(level) + } + } + .pickerStyle(.radioGroup) + .onChange(of: logLevel) { newValue in + AppGroupConfig.logLevel = newValue + AppLogger.log(.info, "Log level: \(newValue)") + } + } + } + + Text(NSLocalizedString("Restart VPN to apply", comment: "")) + .font(.system(size: 9)) + .foregroundColor(.secondary) + + Spacer() + } + .padding(16) + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add macos/GostX/LogOptionsView.swift +git commit -m "feat: add LogOptionsView (toggle + level picker)" +``` + +--- + +### Task 3: Create LogContentView + +**Files:** +- Create: `macos/GostX/LogContentView.swift` + +**Interfaces:** +- Consumes: `LogViewModel` (lines, isFollowing, scrollProxy, onAppear, onDisappear, copyAll, clearLog), `loggingEnabled` Bool binding +- Produces: `struct LogContentView: View` + +- [ ] **Step 1: Write LogContentView** + +Create `macos/GostX/LogContentView.swift`: + +```swift +// macos/GostX/LogContentView.swift +import SwiftUI + +@available(macOS 14.0, *) +struct LogContentView: View { + @ObservedObject var vm: LogViewModel + let loggingEnabled: Bool + + var body: some View { + VStack(spacing: 0) { + // Toolbar + HStack(spacing: 8) { + Button(action: { vm.isFollowing.toggle() }) { + Image(systemName: vm.isFollowing ? "pause.fill" : "play.fill") + } + .buttonStyle(.borderless) + .help(vm.isFollowing + ? NSLocalizedString("Pause auto-scroll", comment: "") + : NSLocalizedString("Resume auto-scroll", comment: "")) + + Divider() + .frame(height: 16) + + Button(action: { vm.copyAll() }) { + Image(systemName: "doc.on.doc") + } + .buttonStyle(.borderless) + .help(NSLocalizedString("Copy all", comment: "")) + + Button(action: { vm.clearLog() }) { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .help(NSLocalizedString("Clear log", comment: "")) + + Spacer() + + Text("\(vm.lines.count) lines") + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(height: 32) + + Divider() + + // Content + if !loggingEnabled { + VStack { + Spacer() + Image(systemName: "text.alignleft") + .font(.system(size: 28)) + .foregroundColor(Color(nsColor: .tertiaryLabelColor)) + Text(NSLocalizedString("Logging is off", comment: "")) + .font(.system(size: 13)) + .foregroundColor(.secondary) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if vm.lines.isEmpty { + VStack { + Spacer() + Image(systemName: "text.alignleft") + .font(.system(size: 28)) + .foregroundColor(Color(nsColor: .tertiaryLabelColor)) + Text(NSLocalizedString("No logs", comment: "")) + .font(.system(size: 13)) + .foregroundColor(.secondary) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 1) { + ForEach(Array(vm.lines.enumerated()), id: \.offset) { _, line in + Text(line) + .font(.system(size: 11, design: .monospaced)) + .textSelection(.enabled) + } + } + .padding(8) + } + .background(Color(nsColor: .textBackgroundColor)) + .onAppear { vm.scrollProxy = proxy } + } + } + } + .onAppear { vm.onAppear() } + .onDisappear { vm.onDisappear() } + .onChange(of: vm.isFollowing) { _ in + if vm.isFollowing, let proxy = vm.scrollProxy, !vm.lines.isEmpty { + proxy.scrollTo(vm.lines.count - 1, anchor: .bottom) + } + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add macos/GostX/LogContentView.swift +git commit -m "feat: add LogContentView (scrollable lines + toolbar)" +``` + +--- + +### Task 4: Create ProfileListView + +**Files:** +- Create: `macos/GostX/ProfileListView.swift` + +**Interfaces:** +- Consumes: `ConfigRepository.shared` (ObservedObject), `@Binding var selectedProfileId: String?` +- Produces: `struct ProfileListView: View` — profile list with add/rename/delete + +- [ ] **Step 1: Write ProfileListView** + +Extract the profile list sidebar from `SettingsView.swift` into `macos/GostX/ProfileListView.swift`: + +```swift +// macos/GostX/ProfileListView.swift +import SwiftUI + +@available(macOS 14.0, *) +struct ProfileListView: View { + @ObservedObject var repo = ConfigRepository.shared + @Binding var selectedProfileId: String? + @State private var showAddSheet = false + @State private var showRenameSheet = false + @State private var newProfileName = "" + @State private var renameTargetId: String? = nil + + var body: some View { + VStack(spacing: 0) { + List(selection: $selectedProfileId) { + ForEach(repo.profiles) { profile in + Label(profile.name, systemImage: "doc.text") + .tag(profile.id) + .contextMenu { + Button(NSLocalizedString("Rename...", comment: "")) { + renameTargetId = profile.id + newProfileName = profile.name + showRenameSheet = true + } + Divider() + Button(NSLocalizedString("Delete...", comment: ""), role: .destructive) { + repo.deleteProfile(profile.id) + } + } + } + } + .listStyle(.plain) + + Divider() + + HStack { + Button(action: { showAddSheet = true }) { + Label(NSLocalizedString("Add Profile", comment: ""), systemImage: "plus") + } + .buttonStyle(.borderless) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(height: 32) + } + .onAppear { + if selectedProfileId == nil, let first = repo.profiles.first { + selectedProfileId = first.id + } + } + .sheet(isPresented: $showAddSheet) { + addProfileSheet + } + .sheet(isPresented: $showRenameSheet) { + renameProfileSheet + } + } + + private var addProfileSheet: some View { + VStack(spacing: 16) { + Text(NSLocalizedString("New Profile", comment: "")).font(.headline) + TextField(NSLocalizedString("Profile name", comment: ""), text: $newProfileName) + .textFieldStyle(.roundedBorder) + .frame(width: 250) + HStack { + Button(NSLocalizedString("Cancel", comment: "")) { showAddSheet = false } + .keyboardShortcut(.cancelAction) + Button(NSLocalizedString("Add", comment: "")) { + if !newProfileName.isEmpty { + let newId = repo.addProfile(name: newProfileName) + if let id = newId { selectedProfileId = id } + newProfileName = "" + showAddSheet = false + } + } + .keyboardShortcut(.defaultAction) + .disabled(newProfileName.isEmpty) + } + } + .padding() + .frame(width: 300, height: 140) + } + + private var renameProfileSheet: some View { + VStack(spacing: 16) { + Text(NSLocalizedString("Rename Profile", comment: "")).font(.headline) + TextField(NSLocalizedString("Profile name", comment: ""), text: $newProfileName) + .textFieldStyle(.roundedBorder) + .frame(width: 250) + HStack { + Button(NSLocalizedString("Cancel", comment: "")) { showRenameSheet = false } + .keyboardShortcut(.cancelAction) + Button(NSLocalizedString("Rename", comment: "")) { + if let id = renameTargetId, !newProfileName.isEmpty { + _ = repo.renameProfile(id, newName: newProfileName) + newProfileName = "" + renameTargetId = nil + showRenameSheet = false + } + } + .keyboardShortcut(.defaultAction) + .disabled(newProfileName.isEmpty) + } + } + .padding() + .frame(width: 300, height: 140) + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add macos/GostX/ProfileListView.swift +git commit -m "feat: add ProfileListView extracted from SettingsView" +``` + +--- + +### Task 5: Create FileListView + +**Files:** +- Create: `macos/GostX/FileListView.swift` + +**Interfaces:** +- Consumes: `@ObservedObject var vm: FileManageViewModel` +- Produces: `struct FileListView: View` — file list + context menu + import/new/rename/delete + +- [ ] **Step 1: Write FileListView** + +Extract the sidebar portion from `FileManageView` into `macos/GostX/FileListView.swift`. Keep all the dialogs, sheets, and modifiers that belong to the list. Note: `formatFileSize`, `formatFileDate`, `FileRowView`, `FileManageAlertModifier`, `OverwriteSheetModifier`, and `FileManageDialogsModifier` move from `FileManageView.swift` to here since they are all used by the list sidebar. + +```swift +// macos/GostX/FileListView.swift +import SwiftUI +import AppKit + +// MARK: - Helpers + +private func formatFileSize(_ bytes: Int64) -> String { + switch bytes { + case 0..<1024: return "\(bytes) B" + case 1024..<(1024 * 1024): return "\(bytes / 1024) KB" + default: return String(format: "%.1f MB", Double(bytes) / (1024.0 * 1024.0)) + } +} + +private func formatFileDate(_ date: Date) -> String { + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM-dd HH:mm" + return fmt.string(from: date) +} + +// MARK: - FileListView + +@available(macOS 14.0, *) +struct FileListView: View { + @ObservedObject var vm: FileManageViewModel + @State private var showImporter = false + @State private var showExporter = false + @State private var exportName: String? + @State private var renameTarget: FileInfo? + @State private var renameText: String = "" + @State private var deleteTarget: FileInfo? + @State private var showDeleteConfirm = false + @State private var showNewFileSheet = false + @State private var newFileName = "" + + var body: some View { + VStack(spacing: 0) { + if vm.files.isEmpty { + VStack(spacing: 8) { + Spacer() + Text(NSLocalizedString("No files", comment: "")) + .font(.system(size: 12)) + .foregroundColor(.secondary) + Spacer() + } + } else { + List(selection: Binding( + get: { vm.selectedFileName }, + set: { name in + DispatchQueue.main.async { + if let name { + vm.selectFile(name) + } else { + vm.selectedFileName = nil + vm.fileContent = "" + } + } + } + )) { + ForEach(vm.files) { file in + FileRowView( + file: file, + onExport: { + exportName = file.name + showExporter = true + }, + onRename: { + renameTarget = file + renameText = file.name + }, + onCopyPath: { vm.copyPath(file.name) }, + onDelete: { + deleteTarget = file + showDeleteConfirm = true + } + ) + .tag(file.name) + } + } + .listStyle(.plain) + } + + Divider() + + HStack { + Button(action: { showNewFileSheet = true }) { + Label(NSLocalizedString("New File", comment: ""), systemImage: "doc.badge.plus") + } + .buttonStyle(.borderless) + Spacer() + Button(action: { showImporter = true }) { + Label(NSLocalizedString("Import", comment: ""), systemImage: "square.and.arrow.up") + } + .buttonStyle(.borderless) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(height: 32) + } + .fileImporter(isPresented: $showImporter, allowedContentTypes: [.data, .plainText, .text]) { result in + if case .success(let url) = result { + vm.importFile(from: url) + } + } + .fileExporter( + isPresented: $showExporter, + item: exportName ?? "", + contentTypes: [.data], + defaultFilename: exportName ?? "file" + ) { result in + if case .success(let url) = result, let name = exportName { + vm.exportFile(name, to: url) + } + exportName = nil + } + .sheet(isPresented: $showNewFileSheet) { + VStack(spacing: 16) { + Text(NSLocalizedString("New File", comment: "")).font(.headline) + TextField(NSLocalizedString("File name", comment: ""), text: $newFileName) + .textFieldStyle(.roundedBorder) + .frame(width: 250) + HStack { + Button(NSLocalizedString("Cancel", comment: "")) { + showNewFileSheet = false + newFileName = "" + } + .keyboardShortcut(.cancelAction) + Button(NSLocalizedString("Create", comment: "")) { + vm.createFile(newFileName) + newFileName = "" + showNewFileSheet = false + } + .keyboardShortcut(.defaultAction) + .disabled(newFileName.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .padding() + .frame(width: 300, height: 140) + } + .modifier(FileManageAlertModifier(vm: vm)) + .modifier(OverwriteSheetModifier(vm: vm)) + .modifier(FileManageDialogsModifier( + renameTarget: $renameTarget, + renameText: $renameText, + deleteTarget: $deleteTarget, + showDeleteConfirm: $showDeleteConfirm, + vm: vm + )) + } +} + +// MARK: - FileRowView + +@available(macOS 14.0, *) +private struct FileRowView: View { + let file: FileInfo + let onExport: () -> Void + let onRename: () -> Void + let onCopyPath: () -> Void + let onDelete: () -> Void + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "doc.text") + .font(.system(size: 16)) + .foregroundColor(.secondary) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 1) { + Text(file.name) + .font(.system(size: 13)) + .lineLimit(1) + Text(formatFileSize(file.sizeBytes)) + .font(.system(size: 10)) + .foregroundColor(.secondary) + Text(formatFileDate(file.lastModified)) + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + + Spacer() + } + .padding(.vertical, 4) + .contextMenu { + Button(NSLocalizedString("Export...", comment: "")) { onExport() } + Button(NSLocalizedString("Rename...", comment: "")) { onRename() } + Button(NSLocalizedString("Copy Path", comment: "")) { onCopyPath() } + Divider() + Button(NSLocalizedString("Delete...", comment: ""), role: .destructive) { onDelete() } + } + } +} + +// MARK: - Alert Modifier + +@available(macOS 14.0, *) +private struct FileManageAlertModifier: ViewModifier { + @ObservedObject var vm: FileManageViewModel + + func body(content: Content) -> some View { + content.alert( + NSLocalizedString("Error", comment: ""), + isPresented: Binding( + get: { vm.alertMessage != nil }, + set: { if !$0 { vm.alertMessage = nil } } + ), + actions: { + Button(NSLocalizedString("OK", comment: "")) { vm.alertMessage = nil } + }, + message: { + Text(vm.alertMessage ?? "") + } + ) + } +} + +// MARK: - Overwrite Sheet Modifier + +@available(macOS 14.0, *) +private struct OverwriteSheetModifier: ViewModifier { + @ObservedObject var vm: FileManageViewModel + + func body(content: Content) -> some View { + content.sheet(isPresented: Binding( + get: { vm.pendingOverwrite != nil }, + set: { if !$0 { vm.cancelOverwrite() } } + )) { + VStack(spacing: 16) { + Text(NSLocalizedString("File Already Exists", comment: "")) + .font(.headline) + Text(String.localizedStringWithFormat( + NSLocalizedString("A file named \"%@\" already exists. Do you want to replace it?", comment: ""), + vm.pendingOverwrite?.fileName ?? "")) + .font(.system(size: 13)) + .multilineTextAlignment(.center) + .frame(width: 300) + HStack(spacing: 12) { + Button(NSLocalizedString("Cancel", comment: "")) { vm.cancelOverwrite() } + .keyboardShortcut(.cancelAction) + Button(NSLocalizedString("Replace", comment: "")) { vm.confirmOverwrite() } + .keyboardShortcut(.defaultAction) + } + } + .padding() + .frame(width: 340, height: 160) + } + } +} + +// MARK: - Rename / Delete Dialogs Modifier + +@available(macOS 14.0, *) +private struct FileManageDialogsModifier: ViewModifier { + @Binding var renameTarget: FileInfo? + @Binding var renameText: String + @Binding var deleteTarget: FileInfo? + @Binding var showDeleteConfirm: Bool + let vm: FileManageViewModel + + func body(content: Content) -> some View { + content + .sheet(isPresented: Binding( + get: { renameTarget != nil }, + set: { if !$0 { renameTarget = nil } } + )) { + VStack(spacing: 16) { + Text(NSLocalizedString("Rename File", comment: "")).font(.headline) + TextField(NSLocalizedString("File name", comment: ""), text: $renameText) + .textFieldStyle(.roundedBorder) + .frame(width: 250) + HStack { + Button(NSLocalizedString("Cancel", comment: "")) { renameTarget = nil } + .keyboardShortcut(.cancelAction) + Button(NSLocalizedString("Rename", comment: "")) { + if let target = renameTarget, !renameText.trimmingCharacters(in: .whitespaces).isEmpty { + vm.renameFile(target.name, to: renameText) + renameTarget = nil + } + } + .keyboardShortcut(.defaultAction) + .disabled(renameText.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .padding() + .frame(width: 320, height: 150) + } + .alert( + NSLocalizedString("Delete File", comment: ""), + isPresented: $showDeleteConfirm, + presenting: deleteTarget + ) { file in + Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) { + deleteTarget = nil + } + Button(NSLocalizedString("Delete", comment: ""), role: .destructive) { + vm.deleteFile(file.name) + deleteTarget = nil + } + } message: { file in + Text(String.localizedStringWithFormat( + NSLocalizedString("Are you sure you want to delete \"%@\"? This cannot be undone.", comment: ""), + file.name)) + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add macos/GostX/FileListView.swift +git commit -m "feat: add FileListView extracted from FileManageView" +``` + +--- + +### Task 6: Create FileContentView + +**Files:** +- Create: `macos/GostX/FileContentView.swift` + +**Interfaces:** +- Consumes: `@ObservedObject var vm: FileManageViewModel` (reads `fileContent`, `isFileDirty`, `selectedFileName`; calls `saveFileContent()`) +- Produces: `struct FileContentView: View` + +- [ ] **Step 1: Write FileContentView** + +Extract the detail pane from `FileManageView` into `macos/GostX/FileContentView.swift`: + +```swift +// macos/GostX/FileContentView.swift +import SwiftUI + +@available(macOS 14.0, *) +struct FileContentView: View { + @ObservedObject var vm: FileManageViewModel + + var body: some View { + Group { + if vm.selectedFileName != nil { + VStack(spacing: 0) { + TextEditor(text: $vm.fileContent) + .font(.system(size: 12, design: .monospaced)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onChange(of: vm.fileContent) { _ in + DispatchQueue.main.async { + vm.isFileDirty = vm.fileContent != vm.originalContent + } + } + + Divider() + + HStack { + Spacer() + Button(action: { vm.saveFileContent() }) { + Label(NSLocalizedString("Save", comment: ""), systemImage: "square.and.arrow.down") + } + .buttonStyle(.borderless) + .disabled(!vm.isFileDirty) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(height: 32) + } + } else { + VStack { + Image(systemName: "doc.text.magnifyingglass") + .font(.system(size: 28)) + .foregroundColor(Color(nsColor: .tertiaryLabelColor)) + Text(NSLocalizedString("Select a file to view", comment: "")) + .font(.system(size: 13)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add macos/GostX/FileContentView.swift +git commit -m "feat: add FileContentView extracted from FileManageView" +``` + +--- + +### Task 7: Rewrite SettingsView as NavigationSplitView + +**Files:** +- Modify: `macos/GostX/SettingsView.swift` + +**Interfaces:** +- Consumes: All new views from Tasks 1-6, `ConfigRepository.shared`, `FileManageViewModel`, `LogViewModel` +- Produces: `struct SettingsView: View` using `NavigationSplitView` + +**Note:** The `YamlEditorView` struct stays in this file unchanged. Only `SettingsView` is rewritten. We add the `SettingsCategory` enum and `FileManageViewModel` / `LogViewModel` as `@StateObject`. + +- [ ] **Step 1: Rewrite SettingsView.swift** + +Replace the entire contents of `macos/GostX/SettingsView.swift`: + +```swift +// macos/GostX/SettingsView.swift +import SwiftUI + +// MARK: - Category + +enum SettingsCategory: String, CaseIterable, Identifiable { + case profiles + case files + case logs + + var id: Self { self } + + var icon: String { + switch self { + case .profiles: return "doc.text" + case .files: return "folder" + case .logs: return "text.alignleft" + } + } + + var label: String { + switch self { + case .profiles: return NSLocalizedString("Profiles", comment: "") + case .files: return NSLocalizedString("Files", comment: "") + case .logs: return NSLocalizedString("Logs", comment: "") + } + } +} + +// MARK: - SettingsView + +@available(macOS 14.0, *) +struct SettingsView: View { + @State private var selectedCategory: SettingsCategory = .profiles + + // Profiles + @State private var selectedProfileId: String? = nil + + // Files + @StateObject private var fileVM = FileManageViewModel() + + // Logs + @StateObject private var logVM = LogViewModel() + @State private var loggingEnabled = AppGroupConfig.loggingEnabled + + var body: some View { + NavigationSplitView { + // Sidebar + List(selection: $selectedCategory) { + ForEach(SettingsCategory.allCases) { category in + Label(category.label, systemImage: category.icon) + .tag(category) + } + } + .listStyle(.sidebar) + .navigationSplitViewColumnWidth(min: 140, ideal: 160, max: 200) + } content: { + // Content + switch selectedCategory { + case .profiles: + ProfileListView(selectedProfileId: $selectedProfileId) + .navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 300) + case .files: + FileListView(vm: fileVM) + .navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 300) + case .logs: + LogOptionsView() + .navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 300) + } + } detail: { + // Detail + switch selectedCategory { + case .profiles: + if let profileId = selectedProfileId { + YamlEditorView(profileId: profileId) + .id(profileId) + } else { + placeholderView( + icon: "doc.text.magnifyingglass", + text: NSLocalizedString("Select a profile to edit", comment: "") + ) + } + case .files: + if fileVM.selectedFileName != nil { + FileContentView(vm: fileVM) + } else { + placeholderView( + icon: "doc.text.magnifyingglass", + text: NSLocalizedString("Select a file to view", comment: "") + ) + } + case .logs: + LogContentView(vm: logVM, loggingEnabled: loggingEnabled) + .onAppear { + loggingEnabled = AppGroupConfig.loggingEnabled + } + } + } + .frame(minWidth: 700, minHeight: 440) + } + + private func placeholderView(icon: String, text: String) -> some View { + VStack { + Image(systemName: icon) + .font(.system(size: 28)) + .foregroundColor(Color(nsColor: .tertiaryLabelColor)) + Text(text) + .font(.system(size: 13)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - YAML Editor View + +@available(macOS 14.0, *) +struct YamlEditorView: View { + let profileId: String + @StateObject private var repo = ConfigRepository.shared + @State private var yamlText: String = "" + @State private var isDirty = false + @State private var originalText: String = "" + + var body: some View { + VStack(spacing: 0) { + YamlTextView(text: $yamlText) + .onChange(of: yamlText) { newValue in + isDirty = newValue != originalText + } + .onAppear { + let text = repo.getConfig(profileId) + yamlText = text + originalText = text + isDirty = false + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Divider() + + HStack { + Spacer() + Button(action: { + save() + originalText = yamlText + isDirty = false + }) { + Label(NSLocalizedString("Save", comment: ""), systemImage: "square.and.arrow.down") + } + .buttonStyle(.borderless) + .disabled(!isDirty) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(height: 32) + } + } + + private func save() { + repo.saveConfig(profileId, yaml: yamlText) + if profileId == repo.activeProfileId { + AppGroupConfig.writeYaml(yamlText) + } + } +} + +@available(macOS 14.0, *) +struct SettingsView_Previews: PreviewProvider { + static var previews: some View { + SettingsView() + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add macos/GostX/SettingsView.swift +git commit -m "refactor: rewrite SettingsView as NavigationSplitView" +``` + +--- + +### Task 8: Delete old files and update Xcode project + +**Files:** +- Delete: `macos/GostX/LogView.swift` +- Delete: `macos/GostX/FileManageView.swift` +- Modify: `macos/GostX.xcodeproj/project.pbxproj` + +- [ ] **Step 1: Delete old Swift files** + +```bash +rm macos/GostX/LogView.swift macos/GostX/FileManageView.swift +``` + +- [ ] **Step 2: Update Xcode project file** + +Use `ruby` to update `project.pbxproj` — remove the two old file references and add the six new ones: + +```bash +cd macos +ruby -e ' +require "xcodeproj" + +project = Xcodeproj::Project.open("GostX.xcodeproj") +target = project.targets.find { |t| t.name == "GostX" } +group = project.main_group.find_subpath("GostX", true) + +# Remove old files +["LogView.swift", "FileManageView.swift"].each do |name| + group.files.find { |f| f.path == name }&.remove_from_project +end + +# Add new files +["LogViewModel.swift", "LogOptionsView.swift", "LogContentView.swift", + "ProfileListView.swift", "FileListView.swift", "FileContentView.swift"].each do |name| + path = File.join(File.dirname(__FILE__), name) + File.write(path, "") unless File.exist?(path) # file already created by us + ref = group.new_file(name) + target.source_build_phase.add_file_reference(ref) if target +end + +project.save +puts "Updated project.pbxproj: removed 2 old files, added 6 new files" +' +``` + +If `xcodeproj` gem is not installed, install it first: `gem install xcodeproj` + +Alternatively, open the project in Xcode, remove `LogView.swift` and `FileManageView.swift` from the project navigator (choose "Remove Reference"), then drag the 6 new `.swift` files into the GostX group. + +- [ ] **Step 3: Verify the project builds** + +```bash +cd macos && xcodebuild -project GostX.xcodeproj -scheme GostX -configuration Debug -destination 'platform=macOS' ONLY_ACTIVE_ARCH=YES build 2>&1 | tail -30 +``` +Expected: **BUILD SUCCEEDED** + +- [ ] **Step 4: Commit** + +```bash +git add macos/GostX/LogView.swift macos/GostX/FileManageView.swift macos/GostX.xcodeproj/project.pbxproj +git commit -m "chore: remove old LogView/FileManageView, add new split views to Xcode project" +``` + +--- + +### Task 9: Build and run verification + +**Files:** None (verification only) + +- [ ] **Step 1: Clean build** + +```bash +cd macos && xcodebuild -project GostX.xcodeproj -scheme GostX -configuration Debug -destination 'platform=macOS' ONLY_ACTIVE_ARCH=YES clean build 2>&1 | tail -30 +``` +Expected: **BUILD SUCCEEDED** with zero warnings from our files. + +- [ ] **Step 2: Run tests** + +```bash +cd macos && xcodebuild test -project GostX.xcodeproj -scheme GostX -destination 'platform=macOS' ONLY_ACTIVE_ARCH=YES 2>&1 | tail -30 +``` +Expected: All tests pass. + +- [ ] **Step 3: Manual QA checklist** (run the app): + +1. Open app → Settings window shows three-column NavigationSplitView +2. Click "Profiles" in sidebar → Profile list in Content, YAML editor in Detail +3. Select a profile → YAML editor loads its config +4. Click "Files" → File list in Content, file content editor in Detail +5. Select a file → content loads in editor; edit + save works +6. Click "Logs" → Log options in Content (toggle + level), log content in Detail +7. Toggle logging off → Detail shows "Logging is off" placeholder +8. Resize window narrow → sidebar collapses; system toggle appears in toolbar diff --git a/docs/superpowers/plans/2026-07-13-macos-file-manager.md b/docs/superpowers/plans/2026-07-13-macos-file-manager.md new file mode 100644 index 0000000..8629845 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-macos-file-manager.md @@ -0,0 +1,912 @@ +# macOS File Manager for Bypass Files — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add file management to the macOS sandboxed app for importing/managing gost bypass files in the App Group container. + +**Architecture:** `FileRepository` (data layer operating on `{container}/files/`) → `FileManageViewModel` (ObservableObject bridge) → `FileManageView` (SwiftUI). The existing SettingsView gets a TabView wrapper with Profiles and Files tabs. `PacketTunnelProvider` updates its work directory to `files/`. + +**Tech Stack:** Swift 5.9+, SwiftUI, FileManager, App Group container, NSOpenPanel (via `.fileImporter`) + +## Global Constraints + +- App runs in macOS sandbox — file access must go through NSOpenPanel / `.fileImporter` +- Bypass files live in `{AppGroup container}/files/` — flat directory, no subdirectories +- gost configs reference files by bare name (e.g. `bypass.file.path: china_ip_list.txt`) +- Filenames must not contain `..`, `/`, or be empty +- Hidden files (`.` prefix) are excluded from listing +- `PacketTunnelProvider` creates `files/` dir on tunnel start if missing +- Match existing codebase style: repository in dedicated file, view in its own file, `@StateObject` for observation + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `macos/GostX/FileRepository.swift` (**new**) | Pure data layer: CRUD on `files/` directory in App Group container. `FileInfo` struct + `FileRepository` class. | +| `macos/GostX/FileManageView.swift` (**new**) | `FileManageViewModel` (ObservableObject) + `FileManageView` (SwiftUI). Import/export/rename/delete/copy-path UI. | +| `macos/GostX/SettingsView.swift` (modify) | Wrap existing body in a `TabView` with Profiles + Files tabs. | +| `macos/GostXTunnel/PacketTunnelProvider.swift` (modify) | Change `setWorkDirAndLog()` work dir from container root to `container/files/`. | +| `macos/GostX.xcodeproj/project.pbxproj` (modify) | Add `FileRepository.swift` and `FileManageView.swift` to the GostX target. | + +--- + +### Task 1: FileRepository — Data Layer + +**Files:** +- Create: `macos/GostX/FileRepository.swift` + +**Interfaces:** +- Produces: `FileInfo` struct (Identifiable), `FileRepository` class with `listFiles()`, `exists(_:)`, `importFile(from:)`, `exportFile(_:to:)`, `renameFile(_:to:)`, `deleteFile(_:)`, `filePath(_:)`, `ensureDir()` + +- [ ] **Step 1: Create `FileRepository.swift` with FileInfo and FileRepository** + +```swift +// macos/GostX/FileRepository.swift +import Foundation + +struct FileInfo: Identifiable { + var id: String { name } + let name: String + let sizeBytes: Int64 + let lastModified: Date +} + +class FileRepository { + let workDir: URL + + init?() { + guard let container = AppGroupConfig.containerURL else { return nil } + workDir = container.appendingPathComponent("files") + ensureDir() + } + + // MARK: - Directory + + func ensureDir() { + try? FileManager.default.createDirectory(at: workDir, + withIntermediateDirectories: true, attributes: nil) + } + + // MARK: - List + + func listFiles() -> [FileInfo] { + guard let contents = try? FileManager.default.contentsOfDirectory( + at: workDir, + includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey], + options: .skipsHiddenFiles + ) else { return [] } + return contents + .filter { url in + var isDir: ObjCBool = false + FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir) + return !isDir.boolValue + } + .compactMap { url in + guard let attrs = try? url.resourceValues(forKeys: [ + .fileSizeKey, .contentModificationDateKey + ]) else { return nil } + return FileInfo( + name: url.lastPathComponent, + sizeBytes: Int64(attrs.fileSize ?? 0), + lastModified: attrs.contentModificationDate ?? Date.distantPast + ) + } + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + } + + // MARK: - Exists + + func exists(_ name: String) -> Bool { + guard let _ = try? validateName(name) else { return false } + let url = workDir.appendingPathComponent(name) + var isDir: ObjCBool = false + return FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir) + && !isDir.boolValue + } + + // MARK: - Import + + @discardableResult + func importFile(from sourceURL: URL) throws -> FileInfo { + let name = sourceURL.lastPathComponent + try validateName(name) + let target = workDir.appendingPathComponent(name) + // Remove existing file before copy + if FileManager.default.fileExists(atPath: target.path) { + try FileManager.default.removeItem(at: target) + } + try FileManager.default.copyItem(at: sourceURL, to: target) + let attrs = try target.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]) + return FileInfo( + name: name, + sizeBytes: Int64(attrs.fileSize ?? 0), + lastModified: attrs.contentModificationDate ?? Date() + ) + } + + // MARK: - Export + + func exportFile(_ name: String, to destURL: URL) throws { + try validateName(name) + let source = workDir.appendingPathComponent(name) + guard FileManager.default.fileExists(atPath: source.path) else { + throw FileRepositoryError.fileNotFound(name) + } + if FileManager.default.fileExists(atPath: destURL.path) { + try FileManager.default.removeItem(at: destURL) + } + try FileManager.default.copyItem(at: source, to: destURL) + } + + // MARK: - Rename + + func renameFile(_ oldName: String, to newName: String) throws { + try validateName(oldName) + let trimmed = newName.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { + throw FileRepositoryError.invalidName + } + try validateName(trimmed) + let oldURL = workDir.appendingPathComponent(oldName) + let newURL = workDir.appendingPathComponent(trimmed) + guard FileManager.default.fileExists(atPath: oldURL.path) else { + throw FileRepositoryError.fileNotFound(oldName) + } + if FileManager.default.fileExists(atPath: newURL.path) { + throw FileRepositoryError.fileAlreadyExists(trimmed) + } + try FileManager.default.moveItem(at: oldURL, to: newURL) + } + + // MARK: - Delete + + func deleteFile(_ name: String) throws { + try validateName(name) + let url = workDir.appendingPathComponent(name) + if !FileManager.default.fileExists(atPath: url.path) { return } + var isDir: ObjCBool = false + FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir) + guard !isDir.boolValue else { + throw FileRepositoryError.notAFile(name) + } + try FileManager.default.removeItem(at: url) + } + + // MARK: - Path + + func filePath(_ name: String) -> String { + workDir.appendingPathComponent(name).path + } + + // MARK: - Validation + + private func validateName(_ name: String) throws { + let trimmed = name.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || trimmed.contains("..") || trimmed.contains("/") { + throw FileRepositoryError.invalidName + } + } +} + +// MARK: - Errors + +enum FileRepositoryError: LocalizedError { + case invalidName + case fileNotFound(String) + case fileAlreadyExists(String) + case notAFile(String) + + var errorDescription: String? { + switch self { + case .invalidName: + return NSLocalizedString("Invalid filename.", comment: "") + case .fileNotFound(let name): + return String.localizedStringWithFormat( + NSLocalizedString("File \"%@\" not found.", comment: ""), name) + case .fileAlreadyExists(let name): + return String.localizedStringWithFormat( + NSLocalizedString("File \"%@\" already exists.", comment: ""), name) + case .notAFile(let name): + return String.localizedStringWithFormat( + NSLocalizedString("\"%@\" is not a regular file.", comment: ""), name) + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add macos/GostX/FileRepository.swift +git commit -m "feat: add FileRepository data layer for bypass file management + +Co-Authored-By: Claude " +``` + +--- + +### Task 2: FileManageViewModel — Observing Bridge + +**Files:** +- Create: `macos/GostX/FileManageView.swift` (first part — ViewModel) + +**Interfaces:** +- Consumes: `FileRepository` from Task 1 +- Produces: `FileManageViewModel` class — `ObservableObject` with `@Published var files: [FileInfo]`, `@Published var alertMessage: String?`, `@Published var pendingOverwrite: (sourceURL: URL, fileName: String)?`, `func refresh()`, `func importFile(from:)`, `func confirmOverwrite()`, `func cancelOverwrite()`, `func renameFile(_:to:)`, `func deleteFile(_:)`, `func exportFile(_:to:)`, `func copyPath(_:)` + +- [ ] **Step 1: Create FileManageViewModel at the top of `FileManageView.swift`** + +```swift +// macos/GostX/FileManageView.swift +import SwiftUI +import AppKit + +// MARK: - Helpers + +private func formatFileSize(_ bytes: Int64) -> String { + switch bytes { + case 0..<1024: return "\(bytes) B" + case 1024..<(1024 * 1024): return "\(bytes / 1024) KB" + default: return String(format: "%.1f MB", Double(bytes) / (1024.0 * 1024.0)) + } +} + +private func formatFileDate(_ date: Date) -> String { + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM-dd HH:mm" + return fmt.string(from: date) +} + +// MARK: - ViewModel + +@MainActor +class FileManageViewModel: ObservableObject { + @Published var files: [FileInfo] = [] + @Published var alertMessage: String? + @Published var pendingOverwrite: (sourceURL: URL, fileName: String)? + + private let repo: FileRepository? + + init() { + repo = FileRepository() + refresh() + } + + var isAvailable: Bool { repo != nil } + + func refresh() { + files = repo?.listFiles() ?? [] + } + + func importFile(from sourceURL: URL) { + guard let repo else { return } + let name = sourceURL.lastPathComponent + if repo.exists(name) { + pendingOverwrite = (sourceURL, name) + return + } + doImport(sourceURL: sourceURL) + } + + func confirmOverwrite() { + guard let (url, _) = pendingOverwrite else { return } + pendingOverwrite = nil + doImport(sourceURL: url) + } + + func cancelOverwrite() { + pendingOverwrite = nil + } + + private func doImport(sourceURL: URL) { + guard let repo else { return } + do { + try repo.importFile(from: sourceURL) + refresh() + } catch { + alertMessage = String.localizedStringWithFormat( + NSLocalizedString("Import failed: %@", comment: ""), + error.localizedDescription) + } + } + + func renameFile(_ oldName: String, to newName: String) { + guard let repo else { return } + do { + try repo.renameFile(oldName, to: newName) + refresh() + } catch { + alertMessage = error.localizedDescription + } + } + + func deleteFile(_ name: String) { + guard let repo else { return } + do { + try repo.deleteFile(name) + refresh() + } catch { + alertMessage = error.localizedDescription + } + } + + func exportFile(_ name: String, to destURL: URL) { + guard let repo else { return } + do { + try repo.exportFile(name, to: destURL) + } catch { + alertMessage = error.localizedDescription + } + } + + func copyPath(_ name: String) { + guard let repo else { return } + let path = repo.filePath(name) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(path, forType: .string) + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add macos/GostX/FileManageView.swift +git commit -m "feat: add FileManageViewModel with import/export/rename/delete/copy-path + +Co-Authored-By: Claude " +``` + +--- + +### Task 3: FileManageView — SwiftUI View + +**Files:** +- Modify: `macos/GostX/FileManageView.swift` (append view to existing file) + +**Interfaces:** +- Consumes: `FileManageViewModel` from Task 2 +- Produces: `FileManageView` SwiftUI View + +- [ ] **Step 1: Append FileManageView to `FileManageView.swift`** + +Append the following after the ViewModel code from Task 2: + +```swift +// MARK: - FileManageView + +struct FileManageView: View { + @StateObject private var vm = FileManageViewModel() + @State private var showImporter = false + @State private var showExporter = false + @State private var exportName: String? + @State private var renameTarget: FileInfo? + @State private var renameText: String = "" + @State private var deleteTarget: FileInfo? + @State private var showDeleteConfirm = false + + var body: some View { + if !vm.isAvailable { + unavailableView + } else if vm.files.isEmpty { + emptyView + } else { + fileListView + } + } + + // MARK: - Unavailable + + private var unavailableView: some View { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 32)) + .foregroundColor(.secondary) + Text("App Group container not available.") + .font(.system(size: 13)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Empty + + private var emptyView: some View { + VStack(spacing: 16) { + Image(systemName: "folder") + .font(.system(size: 36)) + .foregroundColor(Color(nsColor: .tertiaryLabelColor)) + Text(NSLocalizedString("No files. Click + to import.", comment: "")) + .font(.system(size: 13)) + .foregroundColor(.secondary) + Button(action: { showImporter = true }) { + Label(NSLocalizedString("Import File", comment: ""), systemImage: "plus") + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .fileImporter(isPresented: $showImporter, allowedContentTypes: [.data, .plainText, .text]) { result in + if case .success(let url) = result { + vm.importFile(from: url) + } + } + .modifier(FileManageAlertModifier(vm: vm)) + .modifier(OverwriteSheetModifier(vm: vm)) + } + + // MARK: - List + + private var fileListView: some View { + VStack(spacing: 0) { + // Toolbar + HStack { + Text(NSLocalizedString("Bypass Files", comment: "")) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.secondary) + Spacer() + Button(action: { showImporter = true }) { + Image(systemName: "plus") + .font(.system(size: 12, weight: .medium)) + } + .buttonStyle(.borderless) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + + Divider() + + // File List + List { + ForEach(vm.files) { file in + FileRowView( + file: file, + onExport: { + exportName = file.name + showExporter = true + }, + onRename: { + renameTarget = file + renameText = file.name + }, + onCopyPath: { vm.copyPath(file.name) }, + onDelete: { + deleteTarget = file + showDeleteConfirm = true + } + ) + } + } + .listStyle(.inset) + } + .frame(minWidth: 300, minHeight: 200) + .fileImporter(isPresented: $showImporter, allowedContentTypes: [.data, .plainText, .text]) { result in + if case .success(let url) = result { + vm.importFile(from: url) + } + } + .fileExporter( + isPresented: $showExporter, + item: exportName as? String ?? "", + contentTypes: [.data], + defaultFilename: exportName ?? "file" + ) { result in + if case .success(let url) = result, let name = exportName { + vm.exportFile(name, to: url) + } + exportName = nil + } + .modifier(FileManageAlertModifier(vm: vm)) + .modifier(OverwriteSheetModifier(vm: vm)) + .modifier(FileManageDialogsModifier( + renameTarget: $renameTarget, + renameText: $renameText, + deleteTarget: $deleteTarget, + showDeleteConfirm: $showDeleteConfirm, + vm: vm + )) + } +} + +// MARK: - FileRowView + +private struct FileRowView: View { + let file: FileInfo + let onExport: () -> Void + let onRename: () -> Void + let onCopyPath: () -> Void + let onDelete: () -> Void + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "doc.text") + .font(.system(size: 16)) + .foregroundColor(.secondary) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 2) { + Text(file.name) + .font(.system(size: 13)) + .lineLimit(1) + Text("\(formatFileSize(file.sizeBytes)) — \(formatFileDate(file.lastModified))") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + + Spacer() + + // Context menu via right-click + Menu { + Button(NSLocalizedString("Export...", comment: "")) { onExport() } + Button(NSLocalizedString("Rename...", comment: "")) { onRename() } + Button(NSLocalizedString("Copy Path", comment: "")) { onCopyPath() } + Divider() + Button(NSLocalizedString("Delete...", comment: ""), role: .destructive) { onDelete() } + } label: { + Image(systemName: "ellipsis.circle") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + .menuStyle(.borderlessButton) + .frame(width: 24) + } + .padding(.vertical, 4) + } +} + +// MARK: - Alert Modifier + +private struct FileManageAlertModifier: ViewModifier { + @ObservedObject var vm: FileManageViewModel + + func body(content: Content) -> some View { + content.alert( + NSLocalizedString("Error", comment: ""), + isPresented: Binding( + get: { vm.alertMessage != nil }, + set: { if !$0 { vm.alertMessage = nil } } + ), + actions: { + Button(NSLocalizedString("OK", comment: "")) { vm.alertMessage = nil } + }, + message: { + Text(vm.alertMessage ?? "") + } + ) + } +} + +// MARK: - Overwrite Sheet Modifier + +private struct OverwriteSheetModifier: ViewModifier { + @ObservedObject var vm: FileManageViewModel + + func body(content: Content) -> some View { + content.sheet(isPresented: Binding( + get: { vm.pendingOverwrite != nil }, + set: { if !$0 { vm.cancelOverwrite() } } + )) { + VStack(spacing: 16) { + Text(NSLocalizedString("File Already Exists", comment: "")) + .font(.headline) + Text(String.localizedStringWithFormat( + NSLocalizedString("A file named \"%@\" already exists. Do you want to replace it?", comment: ""), + vm.pendingOverwrite?.fileName ?? "")) + .font(.system(size: 13)) + .multilineTextAlignment(.center) + .frame(width: 300) + HStack(spacing: 12) { + Button(NSLocalizedString("Cancel", comment: "")) { vm.cancelOverwrite() } + .keyboardShortcut(.cancelAction) + Button(NSLocalizedString("Replace", comment: "")) { vm.confirmOverwrite() } + .keyboardShortcut(.defaultAction) + } + } + .padding() + .frame(width: 340, height: 160) + } + } +} + +// MARK: - Rename / Delete Dialogs Modifier + +private struct FileManageDialogsModifier: ViewModifier { + @Binding var renameTarget: FileInfo? + @Binding var renameText: String + @Binding var deleteTarget: FileInfo? + @Binding var showDeleteConfirm: Bool + let vm: FileManageViewModel + + func body(content: Content) -> some View { + content + // Rename sheet + .sheet(isPresented: Binding( + get: { renameTarget != nil }, + set: { if !$0 { renameTarget = nil } } + )) { + VStack(spacing: 16) { + Text(NSLocalizedString("Rename File", comment: "")).font(.headline) + TextField(NSLocalizedString("File name", comment: ""), text: $renameText) + .textFieldStyle(.roundedBorder) + .frame(width: 250) + HStack { + Button(NSLocalizedString("Cancel", comment: "")) { renameTarget = nil } + .keyboardShortcut(.cancelAction) + Button(NSLocalizedString("Rename", comment: "")) { + if let target = renameTarget, !renameText.trimmingCharacters(in: .whitespaces).isEmpty { + vm.renameFile(target.name, to: renameText) + renameTarget = nil + } + } + .keyboardShortcut(.defaultAction) + .disabled(renameText.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .padding() + .frame(width: 320, height: 150) + } + // Delete confirmation alert + .alert( + NSLocalizedString("Delete File", comment: ""), + isPresented: $showDeleteConfirm, + presenting: deleteTarget + ) { file in + Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) { + deleteTarget = nil + } + Button(NSLocalizedString("Delete", comment: ""), role: .destructive) { + vm.deleteFile(file.name) + deleteTarget = nil + } + } message: { file in + Text(String.localizedStringWithFormat( + NSLocalizedString("Are you sure you want to delete \"%@\"? This cannot be undone.", comment: ""), + file.name)) + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add macos/GostX/FileManageView.swift +git commit -m "feat: add FileManageView with file list, import, export, rename, delete dialogs + +Co-Authored-By: Claude " +``` + +--- + +### Task 4: SettingsView — Add TabView Wrapper + +**Files:** +- Modify: `macos/GostX/SettingsView.swift` + +**Interfaces:** +- Consumes: `FileManageView` from Task 3 +- Produces: Modified `SettingsView` with TabView wrapping Profiles and Files tabs + +- [ ] **Step 1: Modify `SettingsView` body to wrap in TabView** + +Replace the entire body of `struct SettingsView` with a TabView wrapper. The existing `NavigationSplitView` content becomes the Profiles tab. + +Edit `SettingsView.swift` — replace the `body` property (lines 19–93) with: + +```swift + @State private var selectedTab = 0 + + var body: some View { + TabView(selection: $selectedTab) { + profilesTab + .tabItem { + Label(NSLocalizedString("Profiles", comment: ""), systemImage: "doc.text") + } + .tag(0) + FileManageView() + .tabItem { + Label(NSLocalizedString("Files", comment: ""), systemImage: "folder") + } + .tag(1) + } + .frame(minWidth: 700, minHeight: 440) + } + + private var profilesTab: some View { + NavigationSplitView { + VStack(spacing: 0) { + List(selection: $selectedProfileId) { + ForEach(repo.profiles) { profile in + HStack(spacing: 8) { + Image(systemName: "doc.text") + .foregroundColor(.secondary) + .font(.system(size: 13)) + Text(profile.name) + .font(.system(size: 13)) + .lineLimit(1) + } + .padding(.vertical, 3) + .tag(profile.id) + .contextMenu { + Button(NSLocalizedString("Rename...", comment: "")) { + renameTargetId = profile.id + newProfileName = profile.name + showRenameSheet = true + } + Divider() + Button(NSLocalizedString("Delete...", comment: ""), role: .destructive) { + repo.deleteProfile(profile.id) + } + } + } + } + .listStyle(.sidebar) + + Divider() + + Button(action: { showAddSheet = true }) { + Label(NSLocalizedString("Add Profile", comment: ""), systemImage: "plus") + .font(.system(size: 12)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.borderless) + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + .frame(minWidth: 200) + .navigationSplitViewColumnWidth(min: 180, ideal: 220) + } detail: { + if let profileId = selectedProfileId { + YamlEditorView(profileId: profileId) + .id(profileId) + } else { + VStack { + Image(systemName: "doc.text.magnifyingglass") + .font(.system(size: 28)) + .foregroundColor(Color(nsColor: .tertiaryLabelColor)) + Text(NSLocalizedString("Select a profile to edit", comment: "")) + .font(.system(size: 13)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .navigationTitle(selectedProfileId.flatMap { id in + repo.profiles.first { $0.id == id }?.name + } ?? NSLocalizedString("Settings", comment: "")) + .toolbar { ToolbarItem { Spacer() } } + .onAppear { + if selectedProfileId == nil, let first = repo.profiles.first { + selectedProfileId = first.id + } + } + .sheet(isPresented: $showAddSheet) { addProfileSheet } + .sheet(isPresented: $showRenameSheet) { renameProfileSheet } + } +``` + +Also remove these lines from `SettingsView` at the original positions: +- The `.frame(minWidth: 700, minHeight: 440)` (line 78) — moved to TabView level +- The `.navigationTitle(...)` (line 79–81) — moved to profilesTab +- The `.toolbar { ToolbarItem { Spacer() } }` (line 82) — moved to profilesTab +- The `.onAppear { ... }` (lines 83–86) — moved to profilesTab +- The `.sheet(...)` modifiers (lines 88–93) — moved to profilesTab + +- [ ] **Step 2: Read the current `SettingsView.swift` to verify exact line content before editing** + +Run: `cat -n macos/GostX/SettingsView.swift | head -100` + +- [ ] **Step 3: Remove the `frame` modifier from the old body and keep the private view helpers** + +The `addProfileSheet`, `renameProfileSheet`, and private vars remain unchanged — the old `body` computed property is replaced by the TabView-based version above. + +- [ ] **Step 4: Verify the file compiles** + +Open in Xcode: build the `GostX` target (`Cmd+B`). + +- [ ] **Step 5: Commit** + +```bash +git add macos/GostX/SettingsView.swift +git commit -m "feat: wrap SettingsView in TabView with Profiles and Files tabs + +Co-Authored-By: Claude " +``` + +--- + +### Task 5: PacketTunnelProvider — Update Work Directory to `files/` + +**Files:** +- Modify: `macos/GostXTunnel/PacketTunnelProvider.swift:101-110` + +**Interfaces:** +- Consumes: `AppGroupConfig.containerURL` (existing) +- Produces: Updated `setWorkDirAndLog()` that creates `files/` directory and sets `LibgostSetWorkDir` to it + +- [ ] **Step 1: Replace the `setWorkDirAndLog()` method** + +Replace lines 101-110 in `PacketTunnelProvider.swift`: + +```swift + private func setWorkDirAndLog() { + guard let containerURL = AppGroupConfig.containerURL else { + os_log(.error, "[GostX] containerURL is nil!") + return + } + let filesDir = containerURL.appendingPathComponent("files") + try? FileManager.default.createDirectory(at: filesDir, + withIntermediateDirectories: true, attributes: nil) + LibgostSetWorkDir(filesDir.path, nil) + let logFile = containerURL.appendingPathComponent("gost.log").path + LibgostSetLogFile(logFile, nil) + LibgostSetLogLevel("info") + } +``` + +- [ ] **Step 2: Commit** + +```bash +git add macos/GostXTunnel/PacketTunnelProvider.swift +git commit -m "fix: set work directory to files/ subdirectory for bypass file resolution + +Co-Authored-By: Claude " +``` + +--- + +### Task 6: Xcode Project — Add New Files to Target + +**Files:** +- Modify: `macos/GostX.xcodeproj/project.pbxproj` + +**Interfaces:** +- Produces: `FileRepository.swift` and `FileManageView.swift` added to the GostX target + +- [ ] **Step 1: Open the Xcode project and add the files manually** + +1. Open `macos/GostX.xcodeproj` in Xcode +2. In the Project Navigator, select the `GostX` group (under the GostX project) +3. Right-click → **Add Files to "GostX"...** +4. Select `macos/GostX/FileRepository.swift` and `macos/GostX/FileManageView.swift` +5. Ensure **"Copy items if needed"** is unchecked (files are already in the correct location) +6. Ensure the target **GostX** is checked (NOT GostXTunnel) +7. Click **Add** + +- [ ] **Step 2: Verify the files appear in the GostX target build phases** + +In Xcode: Select the GostX project → GostX target → Build Phases → Compile Sources. +Verify `FileRepository.swift` and `FileManageView.swift` are listed. + +- [ ] **Step 3: Build the project** + +Run: `Cmd+B` in Xcode. Verify no compilation errors. + +- [ ] **Step 4: Commit** + +```bash +git add macos/GostX.xcodeproj/project.pbxproj +git commit -m "chore: add FileRepository and FileManageView to Xcode project + +Co-Authored-By: Claude " +``` + +--- + +## Verification Checklist + +After all tasks are complete, manually verify: + +- [ ] Open the Settings window — both Profiles and Files tabs are visible +- [ ] Profiles tab works as before (profile list, YAML editor) +- [ ] Files tab shows empty state with "No files. Click + to import." +- [ ] Click import → NSOpenPanel appears → select a file → file appears in list +- [ ] Import a file with same name → overwrite sheet appears → Replace works, Cancel works +- [ ] Right-click/click "..." → Export → NSSavePanel appears → file saves correctly +- [ ] Right-click/click "..." → Rename → sheet appears → rename works +- [ ] Rename to invalid name → error alert +- [ ] Rename to existing name → error alert +- [ ] Right-click/click "..." → Copy Path → clipboard has correct path +- [ ] Right-click/click "..." → Delete → confirmation → file removed +- [ ] Start VPN with bypass config → `files/` directory exists in container +- [ ] Tunnel reads bypass files from `files/` correctly diff --git a/docs/superpowers/plans/2026-07-13-macos-tun-implementation.md b/docs/superpowers/plans/2026-07-13-macos-tun-implementation.md new file mode 100644 index 0000000..d1eb57a --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-macos-tun-implementation.md @@ -0,0 +1,896 @@ +# macOS TUN 支持 — 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 为 GostX macOS 客户端添加 TUN VPN 支持,通过 NEPacketTunnelProvider Extension + sing-tun 实现系统全局流量代理转发。 + +**Architecture:** 主 App(SwiftUI 状态栏)通过 NETunnelProviderManager 控制 PacketTunnelProvider Extension。Extension 使用 gomobile bind 编译的 Libgost.xcframework,调用 Go 层的 StartGost + StartTun。Go 层通过扫描 fd 找到 utun 控制 socket,sing-tun 直接在 fd 上读写,复用全部现有 Android TUN 代码。 + +**Tech Stack:** Go (gomobile bind -target macos), Swift (NetworkExtension, AppKit/SwiftUI), sing-tun system stack + +## Global Constraints + +- Go 层 `StartTun(fd, mtu)` / `StopTun()` / `StartGost()` / `StopGost()` 零改动 +- macOS 不需要 `SetSocketProtector`(无路由循环)和 `SetMemoryLimit`(无内存压力) +- utun fd 通过扫描 `/dev/utun` 控制 socket 获取,不使用 KVC 私有 API +- 主 App 和 Extension 通过 App Group `group.cn.liukebin.gostx` 共享 YAML 和日志 +- 保留旧代理模式(非 VPN),两种模式共用 Libgost framework + +--- + +### Task 1: Go — utun fd 发现函数 + +**Files:** +- Create: `libgost/tun_darwin.go` +- Modify: `libgost/tun_test.go` + +**Interfaces:** +- Produces: `func GetTunnelFileDescriptor() int32` — 扫描 fd 0..1024 找到 utun 控制 socket,返回 fd 或 -1 + +- [ ] **Step 1: 创建 `libgost/tun_darwin.go`** + +```go +//go:build darwin + +package libgost + +import ( + "golang.org/x/sys/unix" +) + +// GetTunnelFileDescriptor scans file descriptors 0..1024 for the utun +// control socket (com.apple.net.utun_control) and returns its fd. +// Returns -1 if no utun socket is found. +// +// Implementation adapted from wireguard-apple's WireGuardAdapter.tunnelFileDescriptor. +func GetTunnelFileDescriptor() int32 { + ctlInfo := &unix.CtlInfo{} + copy(ctlInfo.Name[:], "com.apple.net.utun_control") + for fd := int32(0); fd < 1024; fd++ { + addr, err := unix.Getpeername(int(fd)) + if err != nil { + continue + } + addrCTL, ok := addr.(*unix.SockaddrCtl) + if !ok { + continue + } + if ctlInfo.Id == 0 { + if err = unix.IoctlCtlInfo(int(fd), ctlInfo); err != nil { + continue + } + } + if addrCTL.ID == ctlInfo.Id { + return fd + } + } + return -1 +} +``` + +- [ ] **Step 2: 添加测试到 `libgost/tun_test.go`** + +在文件末尾追加: + +```go +import "runtime" + +func TestGetTunnelFileDescriptor(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("GetTunnelFileDescriptor is darwin-only") + } + // When no utun is active (typical unit test env), returns -1 without error. + fd := GetTunnelFileDescriptor() + if fd != -1 { + t.Logf("found existing utun fd: %d", fd) + } +} +``` + +- [ ] **Step 3: 运行测试** + +```bash +cd libgost && go test -v -run TestGetTunnelFileDescriptor . +``` + +Expected: PASS (macOS 上返回 -1 或找到已存在的 utun fd) + +- [ ] **Step 4: 验证编译** + +```bash +cd libgost && GOOS=darwin GOARCH=arm64 go build . +cd libgost && GOOS=darwin GOARCH=amd64 go build . +``` + +Expected: 编译成功无错误 + +- [ ] **Step 5: Commit** + +```bash +cd /Users/kbliu/Workspace/project/GostX +git add libgost/tun_darwin.go libgost/tun_test.go +git commit -m "feat: add GetTunnelFileDescriptor for macOS utun fd discovery" +``` + +--- + +### Task 2: Go — Makefile 新增 macos-framework target + +**Files:** +- Modify: `libgost/Makefile` + +- [ ] **Step 1: 在 `libgost/Makefile` 末尾追加 macos-framework target** + +```makefile +# macOS .xcframework via gomobile bind +# Produces Frameworks/Libgost.xcframework with macos-arm64_x86_64 slices. +# Xcode links this into the PacketTunnelProvider Extension target. +macos-framework: + CGO_ENABLED=1 GOFLAGS="-buildvcs=false" $(GOMOBILE) bind \ + -target macos \ + -trimpath -ldflags="-s -w" \ + -o Libgost.xcframework \ + . + mkdir -p ../macos/Frameworks + rm -rf ../macos/Frameworks/Libgost.xcframework + mv Libgost.xcframework ../macos/Frameworks/Libgost.xcframework + @echo "Libgost.xcframework → macos/Frameworks/" +``` + +同时更新 `.PHONY` 和 `clean`: + +在 `libgost/Makefile` 顶部修改 `.PHONY` 行: + +```makefile +.PHONY: all clean debug-symbols macos-framework +``` + +在 `clean` target 末尾追加: + +```makefile + rm -rf ../macos/Frameworks/Libgost.xcframework +``` + +- [ ] **Step 2: 构建 xcframework 并验证** + +```bash +cd libgost && make macos-framework +ls -la ../macos/Frameworks/Libgost.xcframework/ +``` + +Expected: `macos-arm64_x86_64/` 目录存在,内含 `Libgost.framework` + +- [ ] **Step 3: 验证 framework 包含所有 Go 导出函数** + +```bash +nm ../macos/Frameworks/Libgost.xcframework/macos-arm64_x86_64/Libgost.framework/Versions/A/Libgost | grep -i "starttun\|stopTun\|startGost\|stopGost\|getTunnelFileDescriptor\|getStatus\|validateConfig\|setLog\|setWorkDir" +``` + +Expected: 输出包含 `StartTun`、`StopTun`、`StartGost`、`StopGost`、`GetTunnelFileDescriptor` 等符号 + +- [ ] **Step 4: Commit** + +```bash +cd /Users/kbliu/Workspace/project/GostX +git add libgost/Makefile +git commit -m "build: add macos-framework target for gomobile bind -target macos" +``` + +--- + +### Task 3: Xcode — 创建 Extension target + +**Files:** +- Create: `macos/GostXTunnel/Info.plist` +- Create: `macos/GostXTunnel/GostXTunnel.entitlements` + +**Note:** 本 task 创建 Extension 所需的文件。Xcode target 创建(`GostXTunnel`)需要在 Xcode UI 中完成,步骤在最后说明。 + +- [ ] **Step 1: 创建 `macos/GostXTunnel/Info.plist`** + +```xml + + + + + CFBundleDisplayName + GostX Tunnel + CFBundleName + $(PRODUCT_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + NSExtension + + NSExtensionPointIdentifier + com.apple.networkextension.packet-tunnel + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).PacketTunnelProvider + + + +``` + +- [ ] **Step 2: 创建 `macos/GostXTunnel/GostXTunnel.entitlements`** + +```xml + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.cn.liukebin.gostx + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.developer.networking.networkextension + + packet-tunnel-provider + + + +``` + +- [ ] **Step 3: 在 Xcode 中创建 Extension target** + +打开 `macos/GostX.xcodeproj`,按以下步骤操作: + +1. **File → New → Target** → 选择 **macOS → Network Extension** → **Packet Tunnel Provider** +2. Product Name: `GostXTunnel` +3. Bundle Identifier: `cn.liukebin.gostx.tunnel` +4. Team: 选择你的 Apple Developer team +5. 删除 Xcode 自动生成的 `PacketTunnelProvider.swift`(将用我们自己写的替换) +6. 右键 GostXTunnel group → **Add Files** → 选择刚创建的 `Info.plist` 和 `GostXTunnel.entitlements` +7. Build Settings → Code Signing Entitlements → 设置为 `GostXTunnel/GostXTunnel.entitlements` +8. Build Settings → Info.plist File → 设置为 `GostXTunnel/Info.plist` +9. Build Phases → Link Binary With Libraries → 点击 + → Add Other → 选择 `macos/Frameworks/Libgost.xcframework` + +- [ ] **Step 4: 验证 target 配置** + +在 Xcode 中选择 GostXTunnel scheme,按 Cmd+B 构建。 + +Expected: 构建成功(会有一个空的 PacketTunnelProvider.swift 报错,下一步解决) + +- [ ] **Step 5: Commit** + +```bash +cd /Users/kbliu/Workspace/project/GostX +git add macos/GostXTunnel/ +git add macos/GostX.xcodeproj/ +git commit -m "feat: add GostXTunnel PacketTunnelProvider Extension target scaffold" +``` + +--- + +### Task 4: Swift — App Group 配置共享辅助类 + +**Files:** +- Create: `macos/GostX/AppGroupConfig.swift` + +**Interfaces:** +- Produces: `struct AppGroupConfig` — 提供 `readYaml()` / `writeYaml(_:)` 静态方法,通过 App Group 容器共享 YAML + +- [ ] **Step 1: 创建 `macos/GostX/AppGroupConfig.swift`** + +```swift +// macos/GostX/AppGroupConfig.swift +import Foundation + +/// 通过 App Group 容器在主 App 和 Extension 之间共享 YAML 配置。 +struct AppGroupConfig { + static let groupId = "group.cn.liukebin.gostx" + static let yamlFileName = "gost.yaml" + + static var containerURL: URL? { + FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: groupId + ) + } + + static var yamlFileURL: URL? { + containerURL?.appendingPathComponent(yamlFileName) + } + + /// 从 App Group 容器读取 YAML 配置字符串。 + /// 返回 nil 表示容器不可用或文件不存在。 + static func readYaml() -> String? { + guard let url = yamlFileURL else { return nil } + return try? String(contentsOf: url, encoding: .utf8) + } + + /// 将 YAML 配置写入 App Group 容器。 + /// 失败静默忽略(Extension 启动时会检查并报错)。 + static func writeYaml(_ yaml: String) { + guard let url = yamlFileURL else { return } + try? yaml.write(to: url, atomically: true, encoding: .utf8) + } +} +``` + +- [ ] **Step 2: 在主 App target 中添加该文件** + +1. Xcode → GostX target → Build Phases → Compile Sources → 确认 `AppGroupConfig.swift` 在列表中 +2. 同样在 GostXTunnel target → Build Phases → Compile Sources → 添加 `AppGroupConfig.swift` + +这样两个 target 都能使用 `AppGroupConfig`。 + +- [ ] **Step 3: 验证编译** + +Xcode 中依次选择 GostX scheme 和 GostXTunnel scheme,Cmd+B 编译。 + +Expected: 两个 target 均编译成功 + +- [ ] **Step 4: Commit** + +```bash +cd /Users/kbliu/Workspace/project/GostX +git add macos/GostX/AppGroupConfig.swift +git add macos/GostX.xcodeproj/ +git commit -m "feat: add AppGroupConfig helper for YAML sharing via App Group" +``` + +--- + +### Task 5: Swift — PacketTunnelProvider 实现 + +**Files:** +- Create: `macos/GostXTunnel/PacketTunnelProvider.swift` + +**Interfaces:** +- Consumes: `AppGroupConfig.readYaml()`, `LibgostStartGost()`, `LibgostStartTun()`, `LibgostStopTun()`, `LibgostStopGost()`, `LibgostGetTunnelFileDescriptor()`, `LibgostSetLogFile()`, `LibgostSetLogLevel()`, `LibgostSetWorkDir()` +- Produces: `class PacketTunnelProvider: NEPacketTunnelProvider` — VPN Extension 入口 + +- [ ] **Step 1: 创建 `macos/GostXTunnel/PacketTunnelProvider.swift`** + +```swift +// macos/GostXTunnel/PacketTunnelProvider.swift +import NetworkExtension +import Libgost + +class PacketTunnelProvider: NEPacketTunnelProvider { + + override func startTunnel(options: [String: NSObject]?) async throws { + // 1. 设置工作目录和日志 + setWorkDirAndLog() + + // 2. 从 App Group 读取 YAML 配置 + guard let yaml = AppGroupConfig.readYaml(), !yaml.isEmpty else { + cancelTunnelWithError(NSError( + domain: "GostXTunnel", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No YAML config in App Group"] + )) + return + } + + // 3. 启动 gost 服务 + do { + try LibgostStartGost(yaml, "") + } catch { + cancelTunnelWithError(NSError( + domain: "GostXTunnel.startGost", + code: 2, + userInfo: [NSLocalizedDescriptionKey: error.localizedDescription] + )) + return + } + + // 4. 配置 TUN 网络设置(系统自动创建 utun 接口) + let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "10.0.0.1") + settings.mtu = 1500 + + let ipv4 = NEIPv4Settings( + addresses: ["10.0.0.2"], + subnetMasks: ["255.255.255.0"] + ) + ipv4.includedRoutes = [NEIPv4Route.default()] + settings.ipv4Settings = ipv4 + + settings.dnsSettings = NEDNSSettings(servers: ["10.0.0.3"]) + + do { + try await setTunnelNetworkSettings(settings) + } catch { + LibgostStopGost() + cancelTunnelWithError(NSError( + domain: "GostXTunnel.setNetworkSettings", + code: 3, + userInfo: [NSLocalizedDescriptionKey: error.localizedDescription] + )) + return + } + + // 5. 扫描找到 utun fd + let tunFd = LibgostGetTunnelFileDescriptor() + guard tunFd != -1 else { + LibgostStopGost() + cancelTunnelWithError(NSError( + domain: "GostXTunnel", + code: 4, + userInfo: [NSLocalizedDescriptionKey: "Cannot locate TUN file descriptor"] + )) + return + } + + // 6. 启动 TUN stack(sing-tun 直接在 fd 上读写) + do { + try LibgostStartTun(Int(tunFd), 1500) + } catch { + LibgostStopGost() + cancelTunnelWithError(NSError( + domain: "GostXTunnel.startTun", + code: 5, + userInfo: [NSLocalizedDescriptionKey: error.localizedDescription] + )) + return + } + } + + override func stopTunnel(with reason: NEProviderStopReason) async { + LibgostStopTun() + LibgostStopGost() + } + + // MARK: - Private + + private func setWorkDirAndLog() { + guard let containerURL = AppGroupConfig.containerURL else { return } + let workDir = containerURL.path + LibgostSetWorkDir(workDir) + + let logFile = containerURL.appendingPathComponent("gost.log").path + LibgostSetLogFile(logFile) + LibgostSetLogLevel("info") + } +} +``` + +- [ ] **Step 2: 验证编译** + +Xcode → GostXTunnel scheme → Cmd+B + +Expected: 编译成功(需要提前 link Libgost.xcframework 到该 target) + +- [ ] **Step 3: Commit** + +```bash +cd /Users/kbliu/Workspace/project/GostX +git add macos/GostXTunnel/PacketTunnelProvider.swift +git commit -m "feat: implement PacketTunnelProvider with utun TUN stack" +``` + +--- + +### Task 6: Swift — VpnManager(主 App VPN 控制) + +**Files:** +- Create: `macos/GostX/VpnManager.swift` + +- [ ] **Step 1: 创建 `macos/GostX/VpnManager.swift`** + +```swift +// macos/GostX/VpnManager.swift +import Foundation +import NetworkExtension +import Combine + +/// 管理 NETunnelProviderManager 生命周期:加载/保存 VPN 配置、启停连接。 +@MainActor +class VpnManager: ObservableObject { + static let shared = VpnManager() + + @Published var status: NEVPNStatus = .invalid + + private let tunnelBundleId = "cn.liukebin.gostx.tunnel" + private var manager: NETunnelProviderManager? + private var statusObserver: NSObjectProtocol? + + private init() {} + + // MARK: - Setup + + /// 首次调用时加载已存在的 VPN 配置,或创建新的。 + /// 必须在应用启动时调用一次。 + func setup() async { + // 已有 manager 则跳过 + if manager != nil { return } + + let managers = await NETunnelProviderManager.loadAllFromPreferences() + if let existing = managers.first(where: { + ($0.protocolConfiguration as? NETunnelProviderProtocol)? + .providerBundleIdentifier == tunnelBundleId + }) { + manager = existing + status = existing.connection.status + } else { + let m = NETunnelProviderManager() + let proto = NETunnelProviderProtocol() + proto.providerBundleIdentifier = tunnelBundleId + proto.serverAddress = "GostX" + m.protocolConfiguration = proto + m.isEnabled = true + m.localizedDescription = "GostX VPN" + try? await m.saveToPreferences() + manager = m + } + observeStatus() + } + + // MARK: - Control + + func start() async throws { + guard let m = manager else { return } + try m.connection.startVPNTunnel() + } + + func stop() { + manager?.connection.stopVPNTunnel() + } + + // MARK: - Status observation + + private func observeStatus() { + statusObserver = NotificationCenter.default.addObserver( + forName: .NEVPNStatusDidChange, + object: manager?.connection, + queue: .main + ) { [weak self] _ in + guard let self, let m = self.manager else { return } + self.status = m.connection.status + } + } + + deinit { + if let observer = statusObserver { + NotificationCenter.default.removeObserver(observer) + } + } +} +``` + +- [ ] **Step 2: 验证编译** + +Xcode → GostX scheme → Cmd+B + +Expected: 编译成功 + +- [ ] **Step 3: Commit** + +```bash +cd /Users/kbliu/Workspace/project/GostX +git add macos/GostX/VpnManager.swift +git commit -m "feat: add VpnManager for macOS VPN lifecycle control" +``` + +--- + +### Task 7: Swift — 主 App VPN 集成 + +**Files:** +- Modify: `macos/GostX/AppDelegate.swift` +- Modify: `macos/GostX/MacExtrasConfigurator.swift` +- Modify: `macos/GostX/SettingsView.swift` +- Modify: `macos/GostX/GostX.entitlements` +- Modify: `macos/GostX/Arguments.swift` + +- [ ] **Step 1: 更新 `macos/GostX/GostX.entitlements` — 添加 App Group** + +在 `` 内追加: + +```xml +com.apple.security.application-groups + + group.cn.liukebin.gostx + +``` + +完整文件变为: + +```xml + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.application-groups + + group.cn.liukebin.gostx + + + +``` + +- [ ] **Step 2: 更新 `macos/GostX/Arguments.swift` — 添加 App Group key 常量** + +在文件末尾追加: + +```swift +// YAML 持久化 key(UserDefaults) +let defaultsYamlKey = "gost_yaml_config" + +// VPN 模式 key +let defaultsVpnModeKey = "gost_vpn_mode_enabled" + +// App Group 容器目录下 YAML 文件名 +let appGroupYamlFileName = "gost.yaml" +``` + +- [ ] **Step 3: 重写 `macos/GostX/AppDelegate.swift`** + +完整替换文件内容。将旧 `import Gost` 替换为 `import Libgost`,支持 VPN 模式切换: + +```swift +// +// AppDelegate.swift +// GostX +// + +import SwiftUI +import Cocoa +import os +import Libgost + +let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "runtime") + +@objc protocol EditMenuActions { + func redo(_ sender: AnyObject) + func undo(_ sender: AnyObject) +} + +class AppDelegate: NSObject, NSApplicationDelegate { + private var menu: MacExtrasConfigurator? + private var vpnMode: Bool { + UserDefaults.standard.bool(forKey: defaultsVpnModeKey) + } + + func mainMenu() { + let mainMenu = NSMenu(title: "MainMenu") + var menuItem = mainMenu.addItem(withTitle: "", action: nil, keyEquivalent: "") + var submenu = NSMenu(title: "Application") + mainMenu.setSubmenu(submenu, for: menuItem) + + menuItem = mainMenu.addItem(withTitle:"Edit", action:nil, keyEquivalent:"") + submenu = NSMenu(title:NSLocalizedString("Edit", comment:"Edit menu")) + submenu.addItem(withTitle: "Undo", action: #selector(EditMenuActions.undo(_:)), keyEquivalent: "z") + submenu.addItem(withTitle: "Redo", action: #selector(EditMenuActions.redo(_:)), keyEquivalent: "Z") + submenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x") + submenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c") + submenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v") + submenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") + mainMenu.setSubmenu(submenu, for: menuItem) + + NSApp.mainMenu = mainMenu + } + + func applicationDidFinishLaunching(_ notification: Notification) { + self.mainMenu() + self.menu = MacExtrasConfigurator(delegate: self) + + Task { + await VpnManager.shared.setup() + } + + // Auto-start based on saved state + if UserDefaults.standard.bool(forKey: "last_vpn_running") { + self.start() + } + } + + func applicationWillTerminate(_ notification: Notification) { + self.stop() + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + NSApp.setActivationPolicy(.accessory) + return false + } + + func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } + + func quit() { + NSApp.terminate(self) + } + + func stop() { + if vpnMode { + VpnManager.shared.stop() + } else { + LibgostStopGost() + } + self.menu?.updateListen(nil) + self.menu?.toOffState() + } + + func start() { + if vpnMode { + startVpnMode() + } else { + startProxyMode() + } + } + + // MARK: - VPN Mode + + private func startVpnMode() { + // 同步 YAML 到 App Group + let yaml = UserDefaults.standard.string(forKey: defaultsYamlKey) ?? defaultGostYAML + AppGroupConfig.writeYaml(yaml) + + Task { + do { + try await VpnManager.shared.start() + self.menu?.toOnState() + } catch { + logger.error("VPN start failed: \(error.localizedDescription)") + self.menu?.toOffState() + } + } + } + + // MARK: - Proxy Mode (non-VPN) + + private func startProxyMode() { + let yaml = UserDefaults.standard.string(forKey: defaultsYamlKey) ?? defaultGostYAML + + do { + try LibgostStartGost(yaml, "") + } catch { + logger.error("gost start failed: \(error.localizedDescription)") + self.menu?.toOffState() + return + } + + let statusJSON = LibgostGetStatus() + self.menu?.updateListen(statusJSON) + self.menu?.toOnState() + } +} +``` + +- [ ] **Step 4: 更新 `macos/GostX/MacExtrasConfigurator.swift` — 菜单增加 VPN 模式切换** + +在 `createMenu()` 方法中,`statusListenItem` 之后、分隔线之前插入 VPN 模式切换项: + +```swift +// 在 statusListenItem 添加之后、第一个 separator 之前追加: +let vpnModeItem = NSMenuItem() +vpnModeItem.title = NSLocalizedString("VPN Mode", comment: "") +vpnModeItem.state = UserDefaults.standard.bool(forKey: defaultsVpnModeKey) ? .on : .off +vpnModeItem.target = self +vpnModeItem.action = #selector(onToggleVpnMode(_:)) +menu.addItem(vpnModeItem) +self.vpnModeItem = vpnModeItem +``` + +需要在 `MacExtrasConfigurator` 类中添加属性: + +```swift +private var vpnModeItem: NSMenuItem! +``` + +并添加 action 方法: + +```swift +@objc private func onToggleVpnMode(_ sender: NSMenuItem) { + let newValue = sender.state != .on + sender.state = newValue ? .on : .off + UserDefaults.standard.set(newValue, forKey: defaultsVpnModeKey) +} +``` + +- [ ] **Step 5: 更新 `macos/GostX/SettingsView.swift` — YAML 保存时同步到 App Group** + +在 `YamlConfigView` 的 body 中,给 `yamlConfig` 绑定添加 `.onChange`: + +完整替换 `YamlConfigView`: + +```swift +struct YamlConfigView: View { + @AppStorage(defaultsYamlKey) + private var yamlConfig = defaultGostYAML + + private let rules: [HighlightRule] = [ + HighlightRule( + pattern: yamlCommentRule, + formattingRule: TextFormattingRule(key: .foregroundColor, value: NSColor.systemGray) + ), + HighlightRule( + pattern: yamlKeyRule, + formattingRules: [ + TextFormattingRule(fontTraits: .bold), + TextFormattingRule(key: .foregroundColor, value: NSColor.systemBlue), + ] + ), + ] + + var body: some View { + VStack { + HighlightedTextEditor(text: $yamlConfig, highlightRules: rules) + .introspect { editor in + editor.textView.allowsUndo = true + editor.textView.breakUndoCoalescing() + editor.textView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) + } + .onChange(of: yamlConfig) { newValue in + // Sync to App Group for VPN Extension + AppGroupConfig.writeYaml(newValue) + } + Text("gost v3 YAML configuration — https://gost.run/docs/") + .padding(.horizontal, 5) + .font(Font.system(size: 12)) + .foregroundColor(.gray) + } + } +} +``` + +- [ ] **Step 6: 验证编译** + +Xcode → GostX scheme → Cmd+B + +Expected: 编译成功,无错误 + +- [ ] **Step 7: Commit** + +```bash +cd /Users/kbliu/Workspace/project/GostX +git add macos/GostX/AppDelegate.swift macos/GostX/MacExtrasConfigurator.swift macos/GostX/SettingsView.swift macos/GostX/GostX.entitlements macos/GostX/Arguments.swift +git commit -m "feat: integrate VPN mode into main app with menu toggle and App Group sync" +``` + +--- + +### Task 8: 构建验证 & Xcode 收尾 + +- [ ] **Step 1: 确保 Xcode target 完整配置** + +在 Xcode 中确认以下设置: + +**GostXTunnel target:** +- General → Frameworks and Libraries → `Libgost.xcframework` (Embed & Sign) +- Signing & Capabilities → App Groups → `group.cn.liukebin.gostx` +- Signing & Capabilities → Network Extensions → Packet Tunnel (应自动添加) +- Build Settings → Other Linker Flags → 包含 `-framework Libgost` + +**GostX (主 App) target:** +- Signing & Capabilities → App Groups → `group.cn.liukebin.gostx` + +- [ ] **Step 2: 完整构建** + +Xcode → Product → Build (Cmd+B) 依次选择两个 scheme。 + +Expected: 两个 target 均编译通过 + +- [ ] **Step 3: 运行验证(可选,需要 Developer ID 签名)** + +1. Xcode → GostX scheme → Run (Cmd+R) +2. 在状态栏菜单中启用 **VPN Mode** +3. 点击 Start → 系统弹出 VPN 配置授权 → 允许 +4. 验证 VPN 连接状态 + +Expected: VPN 连接成功,系统流量经 utun 转发 + +- [ ] **Step 4: Commit** + +```bash +cd /Users/kbliu/Workspace/project/GostX +git add macos/GostX.xcodeproj/ +git commit -m "chore: finalize Xcode project settings for macOS VPN build" +``` diff --git a/docs/superpowers/plans/2026-07-14-makefile-macos-target.md b/docs/superpowers/plans/2026-07-14-makefile-macos-target.md new file mode 100644 index 0000000..d1fc947 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-makefile-macos-target.md @@ -0,0 +1,232 @@ +# Makefile macOS Target Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `macos` target to root Makefile that builds `Libgost.xcframework` via `libgost/Makefile`, copies it to `macos/Frameworks/`, then runs `xcodebuild`. + +**Architecture:** `libgost/Makefile` only builds the artifact (`macos-xcframework` target). Root `Makefile` owns the full pipeline: build dependency → copy artifact → invoke Xcode. Same pattern as the existing `android` target → `libgost.aar`. + +**Tech Stack:** GNU Make, gomobile, xcodebuild + +## Global Constraints + +- `libgost/Makefile` must NOT copy files outside `libgost/` directory +- Root `Makefile` handles all file copying to `macos/Frameworks/` +- Target name in libgost: `macos-xcframework` (renamed from `macos-framework`) +- Build flags for gomobile unchanged: `-tags with_gvisor -target macos -trimpath -ldflags="-s -w"` + +--- + +### Task 1: Refactor libgost/Makefile — rename target and strip copy logic + +**Files:** +- Modify: `libgost/Makefile` + +**Interfaces:** +- Produces: `macos-xcframework` phony target — builds `Libgost.xcframework` in `libgost/` directory only + +- [ ] **Step 1: Update `.PHONY` declaration** + +Replace `macos-framework` with `macos-xcframework`. + +In `libgost/Makefile`, line 4, change: + +```makefile +.PHONY: all clean debug-symbols macos-framework +``` + +To: + +```makefile +.PHONY: all clean debug-symbols macos-xcframework +``` + +- [ ] **Step 2: Rename target and remove copy/move logic** + +Replace the `macos-framework` target (lines 53-63) with `macos-xcframework` that only builds, no copy. + +Remove: +```makefile +macos-framework: + CGO_ENABLED=1 GOFLAGS="-buildvcs=false" $(GOMOBILE) bind \ + -tags with_gvisor \ + -target macos \ + -trimpath -ldflags="-s -w" \ + -o Libgost.xcframework \ + . + mkdir -p ../macos/Frameworks + rm -rf ../macos/Frameworks/Libgost.xcframework + mv Libgost.xcframework ../macos/Frameworks/Libgost.xcframework + @echo "Libgost.xcframework → macos/Frameworks/" +``` + +Add: +```makefile +macos-xcframework: + CGO_ENABLED=1 GOFLAGS="-buildvcs=false" $(GOMOBILE) bind \ + -tags with_gvisor \ + -target macos \ + -trimpath -ldflags="-s -w" \ + -o Libgost.xcframework \ + . +``` + +- [ ] **Step 3: Update `clean` target to remove cross-directory cleanup** + +Replace the `clean` target (lines 65-68): + +Remove: +```makefile +clean: + rm -f libgost.aar libgost_debug.aar libgost-sources.jar debug-symbols.zip + rm -rf _debug_tmp + rm -rf ../macos/Frameworks/Libgost.xcframework +``` + +Add: +```makefile +clean: + rm -f libgost.aar libgost_debug.aar libgost-sources.jar debug-symbols.zip Libgost.xcframework + rm -rf _debug_tmp +``` + +- [ ] **Step 4: Commit** + +```bash +git add libgost/Makefile +git commit -m "refactor: rename macos-framework to macos-xcframework, strip copy logic + +- macos-xcframework only builds Libgost.xcframework in libgost/ +- Copy responsibility moved to root Makefile +- clean no longer removes ../macos/Frameworks/ + +Co-Authored-By: Claude " +``` + +--- + +### Task 2: Add macos target and copy rule to root Makefile + +**Files:** +- Modify: `Makefile` + +**Interfaces:** +- Consumes: `macos-xcframework` phony target from `libgost/Makefile` (Task 1) +- Produces: `macos` phony target — builds framework, copies to `macos/Frameworks/`, runs xcodebuild + +- [ ] **Step 1: Add framework build+copy rule** + +In root `Makefile`, add after the `libgost/libgost.aar:` rule (after line 13): + +```makefile +# macOS .xcframework via gomobile bind → libgost/Makefile +macos/Frameworks/Libgost.xcframework: + cd libgost && $(MAKE) macos-xcframework + mkdir -p macos/Frameworks + rm -rf macos/Frameworks/Libgost.xcframework + cp -R libgost/Libgost.xcframework macos/Frameworks/Libgost.xcframework + @echo "Libgost.xcframework → macos/Frameworks/" +``` + +- [ ] **Step 2: Add `macos` target** + +In root `Makefile`, add after the `android-release` target (after line 9): + +```makefile +macos: macos/Frameworks/Libgost.xcframework + cd macos && xcodebuild -project GostX.xcodeproj -scheme GostX -configuration Release build +``` + +- [ ] **Step 3: Update `.PHONY` to include `macos`** + +In root `Makefile`, line 1, change: + +```makefile +.PHONY: all android android-release clean +``` + +To: + +```makefile +.PHONY: all android android-release macos clean +``` + +- [ ] **Step 4: Update `clean` target to remove macOS framework** + +Replace the `clean` target (lines 14-17): + +Remove: +```makefile +clean: + cd libgost && $(MAKE) clean + cd android && ./gradlew clean 2>/dev/null || true +``` + +Add: +```makefile +clean: + cd libgost && $(MAKE) clean + cd android && ./gradlew clean 2>/dev/null || true + rm -rf macos/Frameworks/Libgost.xcframework +``` + +- [ ] **Step 5: Commit** + +```bash +git add Makefile +git commit -m "feat: add macos target to root Makefile + +- macos target builds Libgost.xcframework, copies to macos/Frameworks/, then runs xcodebuild +- mirrors android target pattern (build dep → copy → platform build) +- clean removes macos/Frameworks/Libgost.xcframework + +Co-Authored-By: Claude " +``` + +--- + +### Task 3: Verify the changes + +- [ ] **Step 1: Verify `make -n macos` shows correct dry-run output** + +```bash +cd /Users/kbliu/Workspace/project/GostX && make -n macos 2>&1 | head -20 +``` + +Expected: Shows `cd libgost && make macos-xcframework`, then `mkdir -p macos/Frameworks`, `rm -rf`, `cp -R`, then `xcodebuild` command. No errors. + +- [ ] **Step 2: Verify `make macos-xcframework` builds successfully in libgost** + +```bash +cd /Users/kbliu/Workspace/project/GostX/libgost && make macos-xcframework +``` + +Expected: `Libgost.xcframework` created in `libgost/` directory. No `mkdir`/`mv` output about `../macos/Frameworks`. + +- [ ] **Step 3: Verify framework is not in macos/Frameworks after libgost build** + +```bash +ls /Users/kbliu/Workspace/project/GostX/macos/Frameworks/Libgost.xcframework 2>&1 +``` + +Expected: File not found (unless it already existed from a previous build). + +- [ ] **Step 4: Verify `make clean` in libgost removes the built framework** + +```bash +cd /Users/kbliu/Workspace/project/GostX/libgost && make clean && ls Libgost.xcframework 2>&1 +``` + +Expected: `make clean` succeeds, `ls` reports "No such file or directory". + +- [ ] **Step 5: Verify `make clean` in root removes macos/Frameworks** + +```bash +cd /Users/kbliu/Workspace/project/GostX && make clean +``` + +Expected: Cleans libgost, android (if gradle available), and removes `macos/Frameworks/Libgost.xcframework`. + +- [ ] **Step 6: Commit (if any fixes were needed)** + +Only if changes were made during verification. diff --git a/docs/superpowers/specs/2025-07-16-macos-settings-navigationsplitview-design.md b/docs/superpowers/specs/2025-07-16-macos-settings-navigationsplitview-design.md new file mode 100644 index 0000000..9487d6d --- /dev/null +++ b/docs/superpowers/specs/2025-07-16-macos-settings-navigationsplitview-design.md @@ -0,0 +1,146 @@ +# macOS Settings NavigationSplitView — Design + +**Date:** 2025-07-16 +**Status:** approved + +## Summary + +Replace the custom tab-bar layout in `SettingsView` with a standard macOS `NavigationSplitView` (3-column). The sidebar shows Profiles / Files / Logs categories. Selecting a category populates the Content column with that category's item list, and the Detail column shows the selected item's content. + +## Current vs. Target + +**Current**: Custom tab bar (Profiles / Files / Logs) at top; each tab is a self-contained view. Profiles has a left-right split, Files has a left-right split, Logs is a single scrollable view with inline controls. + +**Target**: `NavigationSplitView` with three columns. The tab bar is replaced by a sidebar. Each category's content is split into list + detail panes. Logs gains a proper split between options (toggle, level) and content (scrollable log lines). + +## Columns + +| Sidebar | Content | Detail | +|---------|---------|--------| +| Profiles | Profile List (+ Add/Rename/Delete) | YAML Editor (or placeholder) | +| Files | File List (+ Import/New/… actions) | File Content Editor (or placeholder) | +| Logs | Log options (toggle, level picker) | Log Content (scrollable lines or empty) | + +### Column behavior + +- Always show all 3 columns with a minimum window width that accommodates them. +- When window is resized narrow, the sidebar hides behind the system toolbar toggle (default NavigationSplitView behavior). + +## State Ownership + +### Category selection — new `@State` in SettingsView + +```swift +enum SettingsCategory: String, CaseIterable, Identifiable { + case profiles, files, logs + var id: Self { self } +} +@State private var selectedCategory: SettingsCategory = .profiles +``` + +### Profiles + +- State stays in `ConfigRepository.shared` (no change). +- `selectedProfileId` lifted from tab-local `@State` into `SettingsView` so sidebar + content + detail share one selection binding. + +### Files + +- `FileManageViewModel` promoted from being created inside `FileManageView` to a `@StateObject` in `SettingsView`. +- Passed down to `FileListView` and `FileContentView`. +- `selectedFileName` binding flows through `FileManageViewModel`. + +### Logs + +- New `LogViewModel: ObservableObject` owned by `SettingsView`. Holds `lines`, `isFollowing`, timer, scroll proxy, plus `copyAll()` / `clearLog()`. +- Log options (`loggingEnabled`, `logLevel`) backed by `AppGroupConfig`, passed as bindings to `LogOptionsView`. +- `LogContentView` observes `LogViewModel.$lines` and `LogViewModel.$isFollowing`. + +## Component Tree + +``` +SettingsView // NavigationSplitView +├─ Sidebar // List(selection: $selectedCategory) +│ ├─ Label("Profiles", "doc.text") +│ ├─ Label("Files", "folder") +│ └─ Label("Logs", "text.alignleft") +│ +├─ Content (switches on selectedCategory) +│ ├─ .profiles → ProfileListView(repo, $selectedProfileId) +│ │ // List of profile names + Add/Rename/Delete context menu +│ ├─ .files → FileListView(vm: FileManageViewModel) +│ │ // List of file names + Import/New/… + context menu +│ └─ .logs → LogOptionsView($loggingEnabled, $logLevel) +│ // Toggle + level picker + "Restart VPN to apply" hint +│ +└─ Detail (switches on selectedCategory) + ├─ .profiles → if let id → YamlEditorView(profileId: id) + │ else → placeholder "Select a profile to edit" + ├─ .files → if let name → FileContentView(vm: FileManageViewModel) + │ else → placeholder "Select a file to view" + └─ .logs → LogContentView(vm: LogViewModel) + // Scrollable log lines or "No logs" / "Logging is off" +``` + +## File Changes + +| File | Change | +|------|--------| +| `SettingsView.swift` | Rewrite as NavigationSplitView orchestrator (~100 lines) | +| `FileManageView.swift` | Delete (gutted into `FileListView` + `FileContentView`) | +| `LogView.swift` | Delete (gutted into `LogOptionsView` + `LogContentView`) | +| New: `ProfileListView.swift` | Extracted from SettingsView profiles tab sidebar | +| New: `FileListView.swift` | Extracted from FileManageView sidebar | +| New: `FileContentView.swift` | Extracted from FileManageView detail pane | +| New: `LogOptionsView.swift` | Logging toggle + level picker | +| New: `LogContentView.swift` | Scrollable log lines + toolbar | +| New: `LogViewModel.swift` | ObservableObject for log state (lines, polling, copy, clear) | + +## Data Flow + +### Profiles +``` +ConfigRepository.shared (@Published profiles, activeProfileId) + → ProfileListView reads repo.profiles + → tap sets selectedProfileId via Binding + → YamlEditorView(profileId:) reads/writes repo.getConfig()/repo.saveConfig() + → Add/Rename/Delete call repo methods, refresh via @Published +``` + +### Files +``` +FileManageViewModel (@StateObject in SettingsView, passes down) + → FileListView reads vm.$files, binds vm.$selectedFileName + → FileContentView read/write vm.fileContent via TextEditor binding + → dirty tracking + save in FileManageViewModel + → Import/Export/Rename/Delete stay in FileManageViewModel (methods + sheets) +``` + +### Logs +``` +LogViewModel (@StateObject in SettingsView) + → .lines, .isFollowing, timer, scrollProxy, .copyAll(), .clearLog() +LogOptionsView binds AppGroupConfig.loggingEnabled / logLevel +LogContentView observes logVM.$lines, logVM.$isFollowing + → if lines.isEmpty && loggingEnabled → polling active, "No logs" placeholder + → if lines.isEmpty && !loggingEnabled → "Logging is off" placeholder + → else → ScrollViewReader with LazyVStack of monospaced lines +``` + +## Edge Cases + +| Case | Handling | +|------|----------| +| No profiles exist | Profile list empty; "Add Profile" button visible; detail shows placeholder | +| Profile deleted while being edited | `selectedProfileId` set to next available (or nil); `YamlEditorView` guarded by `if let id` | +| Log file doesn't exist | `LogViewModel` returns empty array; detail shows "No logs" | +| Logging switched off | `LogViewModel` stops polling, clears lines, detail shows "Logging is off" | +| File deleted while being viewed | `selectedFileName` reset to nil by `deleteFile()`; detail falls to placeholder | +| Window resized narrow | NavigationSplitView collapses sidebar → system toggle button; Content + Detail remain | +| File save when unchanged | Save button disabled via `isFileDirty` (existing logic in ViewModel) | +| Rapid profile switching | `YamlEditorView.id(profileId)` forces full re-creation | + +## Testing + +- `LogViewModel` — new unit tests for: load from file, polling appends lines, clear empties file, copyAll joins lines +- `FileManageViewModel` — verify existing tests still pass; add coverage for `selectFile` / `saveFileContent` if missing +- SwiftUI views — manual QA confirms layout, navigation, and edge cases diff --git a/docs/superpowers/specs/2026-07-13-macos-file-manager-design.md b/docs/superpowers/specs/2026-07-13-macos-file-manager-design.md new file mode 100644 index 0000000..870df24 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-macos-file-manager-design.md @@ -0,0 +1,222 @@ +# macOS File Manager for Bypass Files — Design Spec + +**Date:** 2026-07-13 +**Branch:** `macos` +**Reference:** Android `FileManageScreen` / `FileManageViewModel` / `FileRepository` + +## Purpose + +The macOS app runs in a sandbox. Users need a way to import, manage, and export +files used by gost's bypass feature (e.g., `bypass.file.path: china_ip_list.txt`). +These files must live in the App Group container so the Network Extension can +read them at runtime. + +## Storage + +``` +group.cn.liukebin.gostx/ +├── gost.yaml # YAML config (existing) +├── gost.log # log file (existing) +└── files/ # NEW: bypass file directory + ├── china_ip_list.txt + └── private_ip.txt +``` + +- `FileRepository` operates on `{container}/files/` +- `PacketTunnelProvider.setWorkDirAndLog()` sets `LibgostSetWorkDir` to + `{container}/files/`, so gost configs reference bypass files by bare name + (e.g. `bypass.file.path: china_ip_list.txt`) +- The `files/` directory is created on first use (both by `FileRepository` + on import and by `PacketTunnelProvider` on tunnel start) + +## UI Layout + +The existing Settings window (`NavigationSplitView` with profile sidebar + +YAML editor detail) is wrapped in a **TabView** with two tabs: + +``` +┌─────────────────────────────────────────────┐ +│ [Profiles] [Files] │ +├─────────────────────────────────────────────┤ +│ │ +│ 当前 Tab 的内容 │ +│ │ +└─────────────────────────────────────────────┘ +``` + +- **Profiles Tab** — existing `NavigationSplitView` (unchanged) +- **Files Tab** — new `FileManageView` + +### Files Tab Layout + +A table/`List` view showing imported files: + +| Column | Description | +|---|---| +| Name | File name | +| Size | Formatted size (B / KB / MB) | +| Modified | Last modified date (e.g. `yyyy-MM-dd HH:mm`) | + +- **Empty state:** Centered placeholder text "No files. Click + to import." +- **Toolbar / bottom bar:** `+` button → `NSOpenPanel` for importing files +- **Per-file actions** (context menu / right-click or inline buttons): + - Export → `NSSavePanel` + - Rename → Sheet dialog with text field + - Copy Path → copies full filesystem path to clipboard + - Delete → confirmation alert, then delete + +### Overwrite Handling + +When importing a file whose name already exists in the container, a sheet +dialog asks the user to confirm overwrite or cancel. + +## Components + +### 1. `FileRepository` (new: `macos/GostX/FileRepository.swift`) + +Pure data layer — operates on the `files/` subdirectory of the App Group container. + +```swift +struct FileInfo: Identifiable { + var id: String { name } + let name: String + let sizeBytes: Int64 + let lastModified: Date +} + +class FileRepository { + let workDir: URL // {container}/files/ + + init?() // returns nil if container URL is unavailable + + func listFiles() -> [FileInfo] // sorted by name, excludes dirs & dotfiles + func exists(_ name: String) -> Bool + func importFile(from sourceURL: URL) throws -> FileInfo // copy to workDir + func exportFile(_ name: String, to destURL: URL) throws + func renameFile(_ oldName: String, to newName: String) throws + func deleteFile(_ name: String) throws + func filePath(_ name: String) -> String // full filesystem path + + private func validateName(_ name: String) throws // no "..", "/", empty +} +``` + +### 2. `FileManageViewModel` (new: `macos/GostX/FileManageView.swift`) + +`ObservableObject` bridging `FileRepository` to the SwiftUI view. + +- `@Published files: [FileInfo]` +- `@Published pendingOverwrite: (sourceURL: URL, fileName: String)?` +- `func refresh()` +- `func importFile(from:)` — detects overwrite, delegates to `confirmOverwrite()` or `doImport()` +- `func confirmOverwrite()` / `func cancelOverwrite()` +- `func renameFile(_:to:)` — validates, calls repo, shows error alert on failure +- `func deleteFile(_:)` — calls repo, refreshes +- `func exportFile(_:to:)` — calls repo +- `func copyPath(_:)` — copies `repo.filePath(name)` to `NSPasteboard` + +Error messages are surfaced via `@Published var alertMessage: String?` or +similar alert-pattern state. + +### 3. `FileManageView` (new: `macos/GostX/FileManageView.swift`) + +SwiftUI view: + +- `List` with `FileInfo` items +- `NSOpenPanel` for import, `NSSavePanel` for export +- Sheet for rename, alert for delete confirmation +- Sheets for import-overwrite confirmation +- Empty-state placeholder + +### 4. `SettingsView` (modified: `macos/GostX/SettingsView.swift`) + +Wraps existing content in a `TabView`: + +```swift +TabView(selection: $selectedTab) { + ProfilesTabView() + .tabItem { Label("Profiles", systemImage: "doc.text") } + .tag(0) + FileManageView() + .tabItem { Label("Files", systemImage: "folder") } + .tag(1) +} +``` + +The existing `SettingsView` body becomes `ProfilesTabView`. + +### 5. `PacketTunnelProvider` (modified: `macos/GostXTunnel/PacketTunnelProvider.swift`) + +`setWorkDirAndLog()` updated to create and use the `files/` subdirectory: + +```swift +private func setWorkDirAndLog() { + guard let containerURL = AppGroupConfig.containerURL else { return } + let filesDir = containerURL.appendingPathComponent("files") + try? FileManager.default.createDirectory(at: filesDir, + withIntermediateDirectories: true, attributes: nil) + LibgostSetWorkDir(filesDir.path, nil) + let logFile = containerURL.appendingPathComponent("gost.log").path + LibgostSetLogFile(logFile, nil) + LibgostSetLogLevel("info") +} +``` + +## Error Handling + +| Scenario | Behavior | +|---|---| +| Container URL unavailable | `FileRepository.init?()` returns nil; ViewModel shows error state | +| `files/` directory doesn't exist | Created on first import or tunnel start | +| Import: source unreadable | Throw, show alert with error message | +| Import: duplicate name | Show overwrite confirmation sheet | +| Export: destination unwritable | Throw, show alert | +| Rename: invalid name (`..`, `/`, empty) | Validate, show "Invalid filename" alert | +| Rename: name collision | Show "File already exists" alert | +| Delete: file doesn't exist | No-op (idempotent) | +| Delete: target is a directory | Reject with error (shouldn't happen; `listFiles` excludes dirs) | + +## Edge Cases + +- **Multiple files with same name on import:** Handled by the overwrite-sheet one at a time (each import is a separate operation) +- **Very large files (>100MB):** `FileManager.copyItem` is synchronous; consider showing a progress indicator for large files (stretch goal) +- **Hidden files / macOS resource forks:** `listFiles()` excludes filenames starting with `.`; `._` AppleDouble files are invisible +- **Concurrent import + rename:** Each operation is synchronous on the main queue; no race conditions +- **Tunnel running during file import:** Fine — gost resolves file paths at connection time, and bypass matchers (e.g., `china_ip_list.txt`) are reloaded on config changes or restart + +## Scope / Non-Goals + +- No file preview / content viewer — just metadata +- No subdirectory support — flat file list only (matches Android) +- No drag-and-drop import (stretch goal) +- No progress indicator for large file imports (stretch goal) +- No file watch / auto-reload when bypass files change — user must restart gost + +## Files Changed + +| File | Change | +|---|---| +| `macos/GostX/FileRepository.swift` | **New** — data layer | +| `macos/GostX/FileManageView.swift` | **New** — ViewModel + View | +| `macos/GostX/SettingsView.swift` | Modified — add TabView wrapper | +| `macos/GostXTunnel/PacketTunnelProvider.swift` | Modified — `SetWorkDir` to `files/` | +| `macos/GostX.xcodeproj/project.pbxproj` | Modified — add new files to target | + +## Testing + +Manual verification checklist: + +- [ ] Import a text file via NSOpenPanel; verify it appears in list +- [ ] Import a file with duplicate name; verify overwrite sheet appears +- [ ] Confirm overwrite; verify file is replaced +- [ ] Cancel overwrite; verify original file unchanged +- [ ] Rename a file; verify list updates +- [ ] Rename with invalid name (`..`, `/`, empty); verify error alert +- [ ] Rename to existing name; verify "already exists" alert +- [ ] Export a file via NSSavePanel; verify file saved to chosen location +- [ ] Delete a file; confirm dialog, verify file removed from list +- [ ] Copy path; verify pasteboard contains correct path +- [ ] Empty state displayed when no files exist +- [ ] Start VPN with a bypass config referencing an imported file; verify tunnel works +- [ ] `files/` directory auto-created on first import +- [ ] `files/` directory auto-created on tunnel start (in `setWorkDirAndLog`) diff --git a/docs/superpowers/specs/2026-07-13-macos-tun-design.md b/docs/superpowers/specs/2026-07-13-macos-tun-design.md new file mode 100644 index 0000000..0976546 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-macos-tun-design.md @@ -0,0 +1,299 @@ +# macOS TUN 支持 — 设计文档 + +日期: 2026-07-13 + +## 目标 + +为 GostX macOS 客户端添加 TUN VPN 支持:VPN 启动后,系统所有流量经 utun 接口通过 gost chain 转发。 + +## 架构概览 + +``` +GostX.app +├── 主 App (SwiftUI 状态栏) +│ ├── VpnManager.swift # NETunnelProviderManager 启停 +│ ├── AppDelegate.swift # VPN 模式 + 旧代理模式 +│ ├── SettingsView.swift # YAML 编辑(同步到 App Group) +│ └── MacExtrasConfigurator.swift +│ +├── GostXTunnel.appex (PacketTunnelProvider Extension) +│ └── PacketTunnelProvider.swift +│ ├── startTunnel: +│ │ 1. 从 App Group 读 YAML → StartGost(yaml, "") +│ │ 2. setTunnelNetworkSettings → 系统创建 utun +│ │ 3. GetTunnelFileDescriptor() → 扫描 fd 找 utun +│ │ 4. StartTun(fd, 1500) → sing-tun 直接 fd 读写 +│ └── stopTunnel: +│ 1. StopTun() → StopGost() +│ +└── Frameworks/Libgost.xcframework # gomobile bind -target macos +``` + +### App Group 通信 + +主 App 和 Extension 通过 `group.cn.liukebin.gostx` 共享: +- **YAML 配置**:主 App 写入 → Extension 读取 +- **日志文件**:Extension 写入 → 主 App 读取显示 + +## TUN 数据流 + +``` +Apps → utun → sing-tun system stack (IP header rewrite) + → OS kernel TCP/IP → singTunHandler (NewConnectionEx) + → gost chain Router → 上游代理 → 互联网 +``` + +与 Android 完全一致,不需要 `packetFlow.readPackets()/writePackets()` 桥接。Go sing-tun 直接在 utun fd 上读写。 + +### 路由循环 + +macOS NetworkExtension 的 `setTunnelNetworkSettings` 配置路由后,系统能区分哪些流量走 utun、哪些不走。Extension 进程自身创建的 upstream socket 不会被自己的 VPN 路由捕获,**不需要** Android 的 `protect(fd)` 机制。 + +## Go 层改动 + +### 新增:`tun_darwin.go` — utun fd 发现 + +```go +// +build darwin + +package libgost + +import "golang.org/x/sys/unix" + +// GetTunnelFileDescriptor 扫描 fd 0..1024,找到 com.apple.net.utun_control +// 控制 socket 并返回其 fd。返回 -1 表示未找到。 +// 实现参考 wireguard-apple 的 WireGuardAdapter.tunnelFileDescriptor。 +func GetTunnelFileDescriptor() int32 { + ctlInfo := &unix.CtlInfo{} + copy(ctlInfo.Name[:], "com.apple.net.utun_control") + for fd := int32(0); fd < 1024; fd++ { + addr, err := unix.Getpeername(int(fd)) + if err != nil { + continue + } + addrCTL, ok := addr.(*unix.SockaddrCtl) + if !ok { + continue + } + if ctlInfo.Id == 0 { + if err = unix.IoctlCtlInfo(int(fd), ctlInfo); err != nil { + continue + } + } + if addrCTL.ID == ctlInfo.Id { + return fd + } + } + return -1 +} +``` + +### 不变(零改动) + +| 函数 | 说明 | +|------|------| +| `StartTun(fd int, mtu int) error` | 已支持 Unix fd + sing-tun,不区分平台 | +| `StopTun() error` | 同上 | +| `StartGost(yaml, systemDNS string) error` | systemDNS 传空字符串即可 | +| `StopGost() error` | 同上 | +| `relay()` / `relayPacketConn()` / `singTunHandler` | 全部不变 | + +### macOS 上 no-op + +- `SetMemoryLimit(bool)` — macOS Extension 不需要控 GC,不调用或内部 no-op +- `SetSocketProtector(SocketProtector)` — macOS 不需要 protect,不调用或内部 no-op + +## 编译 & 构建 + +### gomobile bind + +```bash +# libgost/Makefile 新增 +macos-framework: + CGO_ENABLED=1 gomobile bind \ + -target macos \ + -trimpath -ldflags="-s -w" \ + -o ../macos/Frameworks/Libgost.xcframework \ + . +``` + +产物 `Libgost.xcframework` 包含 `macos-arm64_x86_64/`。 + +Go 函数自动映射为 Swift 调用(包名 `libgost` → 前缀 `Libgost`): + +| Go | Swift | +|----|-------| +| `StartTun(fd, mtu) error` | `try LibgostStartTun(_ fd: Int, _ mtu: Int)` | +| `StartGost(yaml, dns) error` | `try LibgostStartGost(_ yaml: String, _ dns: String)` | +| `StopTun() error` | `try LibgostStopTun()` | +| `GetTunnelFileDescriptor() int32` | `LibgostGetTunnelFileDescriptor() -> Int32` | + +## Swift 层改动 + +### PacketTunnelProvider Extension(新增 `macos/GostXTunnel/`) + +核心文件 `PacketTunnelProvider.swift`: + +```swift +import NetworkExtension + +class PacketTunnelProvider: NEPacketTunnelProvider { + + override func startTunnel(options: [String: NSObject]?) async throws { + // 1. 从 App Group 读 YAML + let yaml = loadYamlFromAppGroup() + + // 2. 设置日志 + LibgostSetLogFile(logFilePath) + LibgostSetLogLevel("info") + + // 3. 启动 gost 服务 + try LibgostStartGost(yaml, "") + + // 4. 配置 TUN 路由(系统自动创建 utun) + let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "10.0.0.1") + settings.mtu = 1500 + settings.ipv4Settings = NEIPv4Settings( + addresses: ["10.0.0.2"], + subnetMasks: ["255.255.255.0"] + ) + settings.ipv4Settings!.includedRoutes = [NEIPv4Route.default()] + settings.dnsSettings = NEDNSSettings(servers: ["10.0.0.3"]) + + try await setTunnelNetworkSettings(settings) + + // 5. 找到 utun fd + let tunFd = LibgostGetTunnelFileDescriptor() + guard tunFd != -1 else { + LibgostStopGost() + cancelTunnelWithError(NSError(domain: "cannot locate TUN fd", code: 1)) + return + } + + // 6. 启动 TUN stack + try LibgostStartTun(Int(tunFd), 1500) + } + + override func stopTunnel(with reason: NEProviderStopReason) async { + LibgostStopTun() + LibgostStopGost() + } +} +``` + +### 主 App(修改 `macos/GostX/`) + +**新增 `VpnManager.swift`:** + +```swift +import NetworkExtension + +class VpnManager: ObservableObject { + @Published var state: NEVPNStatus = .invalid + private var manager: NETunnelProviderManager? + + func loadAndSave() async { + let managers = await NETunnelProviderManager.loadAllFromPreferences() + manager = managers.first ?? NETunnelProviderManager() + let proto = NETunnelProviderProtocol() + proto.providerBundleIdentifier = "cn.liukebin.gostx.tunnel" + proto.serverAddress = "GostX" + manager?.protocolConfiguration = proto + manager?.isEnabled = true + try? await manager?.saveToPreferences() + } + + func start() async throws { try manager?.connection.startVPNTunnel() } + func stop() { manager?.connection.stopVPNTunnel() } +} +``` + +**AppDelegate 改动:** +- `start()` 根据模式选择:VPN 模式调 `VpnManager.start()`,代理模式调 `LibgostStartGost(yaml, "")` +- 状态栏菜单新增:VPN 状态 + 启停 +- Settings 保存 YAML 时同步写入 App Group 容器 +- 旧 `import Gost` 及 `gostRunYaml()`/`gostStop()`/`gostInfo()` 全部移除 + +### 替换旧依赖 + +旧 `import Gost`(gost v2 C bridge)完全替换为 `import Libgost`(gomobile xcframework)。 +两种模式共用同一个 Go 层: +- **代理模式**(非 VPN):`LibgostStartGost(yaml)` 启动本地代理 +- **VPN 模式**:`LibgostStartGost(yaml)` + `LibgostStartTun(fd, mtu)` 启动全局转发 + +## Xcode 项目结构 + +``` +macos/ +├── GostX.xcodeproj +├── GostX/ # 主 App target (AppKit) +│ ├── AppDelegate.swift +│ ├── VpnManager.swift # 新增 +│ ├── SettingsView.swift +│ ├── MacExtrasConfigurator.swift +│ ├── Arguments.swift +│ ├── main.swift +│ └── GostX.entitlements +├── GostXTunnel/ # Extension target (新增) +│ ├── PacketTunnelProvider.swift +│ ├── Info.plist +│ └── GostXTunnel.entitlements +└── Frameworks/ # 新增 + └── Libgost.xcframework # gomobile bind 产物 +``` + +## Entitlements + +### 主 App + +```xml +com.apple.security.app-sandbox → true +com.apple.security.network.client → true +com.apple.security.network.server → true +com.apple.security.application-groups → [group.cn.liukebin.gostx] +``` + +### Extension + +```xml +com.apple.security.app-sandbox → true +com.apple.security.application-groups → [group.cn.liukebin.gostx] +com.apple.security.network.client → true +com.apple.security.network.server → true +com.apple.developer.networking.networkextension → [packet-tunnel-provider] +``` + +## 错误处理 + +### 启动失败 + +各级别分别捕获,失败时 `cancelTunnelWithError`: +1. YAML 读取失败 +2. `StartGost` 失败 +3. `setTunnelNetworkSettings` 失败 / utun fd 找不到 +4. `StartTun` 失败(先 `StopGost` 清理) + +### 网络变化 + +`NWPathMonitor` 监听网络切换,调用 Go 层 `ResetTunConnections()`(已有函数)刷新已有连接。 + +### 停止 + +``` +StopTun() → StopGost() +``` + +WireGuard 的 `exit(0)` hack(Apple bug 32073323)先不加,观察 Extension 进程是否能正常回收,如有问题再补。 + +### Extension 崩溃 + +系统自动清理 utun 接口。主 App 通过 `NEVPNStatusDidChange` 更新 UI。 + +## 风险 & 缓解 + +| 风险 | 缓解 | +|------|------| +| Extension 内存限制 | sing-tun 使用 kernel system stack(非 userspace TCP),内存负担小;暂不设 GC 限制,观察 | +| `gomobile bind -target macos` 兼容性 | Apple Silicon 天然支持 arm64;Intel Mac 需 x86_64,gomobile 默认双架构 | +| utun fd 扫描失败 | 概率极低(WireGuard 至今线上一贯用这个方案);返回清晰错误信息 | +| Go 依赖过多致 framework 体积大 | `-ldflags="-s -w"` 去符号;xcframework 切片按架构分 | diff --git a/docs/superpowers/specs/2026-07-14-makefile-macos-target-design.md b/docs/superpowers/specs/2026-07-14-makefile-macos-target-design.md new file mode 100644 index 0000000..687acba --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-makefile-macos-target-design.md @@ -0,0 +1,79 @@ +# Makefile macOS Target Design + +## Overview + +Refactor the macOS build flow in Makefiles to follow the same pattern as Android: root Makefile owns the full build pipeline (build dependency → copy artifact → invoke platform build tool), while `libgost/Makefile` is only responsible for producing the artifact. + +## Changes + +### libgost/Makefile + +#### Rename `macos-framework` → `macos-xcframework` + +Strip the copy/move logic. This target only builds `Libgost.xcframework` in the `libgost/` directory. + +```makefile +macos-xcframework: + CGO_ENABLED=1 GOFLAGS="-buildvcs=false" $(GOMOBILE) bind \ + -tags with_gvisor \ + -target macos \ + -trimpath -ldflags="-s -w" \ + -o Libgost.xcframework \ + . +``` + +**Removed lines:** `mkdir -p ../macos/Frameworks`, `rm -rf ../macos/Frameworks/Libgost.xcframework`, `mv Libgost.xcframework ../macos/Frameworks/Libgost.xcframework`. + +#### Update `.PHONY` + +Replace `macos-framework` with `macos-xcframework`. + +#### Update `clean` + +Remove `rm -rf ../macos/Frameworks/Libgost.xcframework`. Only clean artifacts within `libgost/`: +```makefile +clean: + rm -f libgost.aar libgost_debug.aar libgost-sources.jar debug-symbols.zip Libgost.xcframework + rm -rf _debug_tmp +``` + +### Root Makefile + +#### Add `macos` target + +```makefile +macos: macos/Frameworks/Libgost.xcframework + cd macos && xcodebuild -project GostX.xcodeproj -scheme GostX -configuration Release build +``` + +#### Add framework build+copy rule + +```makefile +macos/Frameworks/Libgost.xcframework: + cd libgost && $(MAKE) macos-xcframework + mkdir -p macos/Frameworks + rm -rf macos/Frameworks/Libgost.xcframework + cp -R libgost/Libgost.xcframework macos/Frameworks/Libgost.xcframework + @echo "Libgost.xcframework → macos/Frameworks/" +``` + +#### Update `clean` + +Add macOS framework cleanup: +```makefile +clean: + cd libgost && $(MAKE) clean + cd android && ./gradlew clean 2>/dev/null || true + rm -rf macos/Frameworks/Libgost.xcframework +``` + +## Target Comparison + +| | Android | macOS (new) | +|---|---|---| +| libgost build target | `libgost.aar` | `macos-xcframework` | +| Artifact location | `libgost/libgost.aar` | `libgost/Libgost.xcframework` | +| Root target | `android` | `macos` | +| Copy destination | `android/app/libs/` | `macos/Frameworks/` | +| Platform build | `./gradlew assembleDebug` | `xcodebuild … build` | +| Clean | `./gradlew clean` | (removes `macos/Frameworks/`) | diff --git a/drawing.svg b/drawing.svg index bb142e8..e21d2d0 100644 --- a/drawing.svg +++ b/drawing.svg @@ -8,8 +8,8 @@ version="1.1" id="svg1" inkscape:export-filename="../../../Desktop/logo.png" - inkscape:export-xdpi="96" - inkscape:export-ydpi="96" + inkscape:export-xdpi="192" + inkscape:export-ydpi="192" inkscape:version="1.3 (0e150ed, 2023-07-21)" sodipodi:docname="drawing.svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" diff --git a/libgost/Makefile b/libgost/Makefile index eeef125..4ebd4cf 100644 --- a/libgost/Makefile +++ b/libgost/Makefile @@ -1,7 +1,7 @@ GO := go GOMOBILE := gomobile -.PHONY: all clean debug-symbols +.PHONY: all clean debug-symbols macos-xcframework all: libgost.aar @@ -47,6 +47,18 @@ debug-symbols: @rm -f libgost_debug.aar @echo "Debug symbols written to debug-symbols.zip" +# macOS .xcframework via gomobile bind +# Produces Libgost.xcframework with macos-arm64_x86_64 slices. +# Xcode links this into the PacketTunnelProvider Extension target. +macos-xcframework: + CGO_ENABLED=1 GOFLAGS="-buildvcs=false" $(GOMOBILE) bind \ + -tags with_gvisor \ + -target macos/arm64 \ + -trimpath -ldflags="-s -w" \ + -o Libgost.xcframework \ + . + clean: rm -f libgost.aar libgost_debug.aar libgost-sources.jar debug-symbols.zip + rm -rf Libgost.xcframework rm -rf _debug_tmp diff --git a/libgost/tun.go b/libgost/tun.go index 1328166..dfa39e3 100644 --- a/libgost/tun.go +++ b/libgost/tun.go @@ -131,10 +131,10 @@ func startVPNSingTun(fd, mtu int, chainName, dnsServiceAddr string) error { if chainer == nil { logrus.Warnf("chain %q not found in registry – traffic will route directly", chainName) } else { - logrus.Infof("chain %q found, sing-tun system stack starting (fd=%d mtu=%d)", chainName, fd, mtu) + logrus.Infof("chain %q found, sing-tun stack starting (fd=%d mtu=%d type=%s)", chainName, fd, mtu, tunStackType) } } else { - logrus.Infof("no chain name – system stack will route directly (fd=%d mtu=%d)", fd, mtu) + logrus.Infof("no chain name – stack will route directly (fd=%d mtu=%d type=%s)", fd, mtu, tunStackType) } router := xchain.NewRouter( gostchain.ChainRouterOption(chainer), @@ -163,7 +163,7 @@ func startVPNSingTun(fd, mtu int, chainName, dnsServiceAddr string) error { router: router, dnsServiceAddr: dnsServiceAddr, } - stack, err := singtun.NewStack("system", singtun.StackOptions{ + stack, err := singtun.NewStack(tunStackType, singtun.StackOptions{ Context: ctx, Tun: device, TunOptions: tunOptions, @@ -182,7 +182,8 @@ func startVPNSingTun(fd, mtu int, chainName, dnsServiceAddr string) error { device.Close() return fmt.Errorf("start sing-tun stack: %w", err) } - logrus.Infof("sing-tun system stack started (tcp_listener=%s/24, dns=%s)", + ifaceName, _ := device.Name() + logrus.Infof("sing-tun stack started (type=%s iface=%s tcp_listener=%s, dns=%s)", tunStackType, ifaceName, tunVPNPrefix, dnsServiceAddr) tunStack = stack diff --git a/libgost/tun_darwin.go b/libgost/tun_darwin.go new file mode 100644 index 0000000..4aea442 --- /dev/null +++ b/libgost/tun_darwin.go @@ -0,0 +1,36 @@ +//go:build darwin + +package libgost + +import ( + "golang.org/x/sys/unix" +) + +// GetTunnelFileDescriptor scans file descriptors 0..1024 for the utun +// control socket (com.apple.net.utun_control) and returns its fd. +// Returns -1 if no utun socket is found. +// +// Implementation adapted from wireguard-apple's WireGuardAdapter.tunnelFileDescriptor. +func GetTunnelFileDescriptor() int32 { + ctlInfo := &unix.CtlInfo{} + copy(ctlInfo.Name[:], "com.apple.net.utun_control") + for fd := int32(0); fd < 1024; fd++ { + addr, err := unix.Getpeername(int(fd)) + if err != nil { + continue + } + addrCTL, ok := addr.(*unix.SockaddrCtl) + if !ok { + continue + } + if ctlInfo.Id == 0 { + if err = unix.IoctlCtlInfo(int(fd), ctlInfo); err != nil { + continue + } + } + if addrCTL.ID == ctlInfo.Id { + return fd + } + } + return -1 +} diff --git a/libgost/tun_darwin_test.go b/libgost/tun_darwin_test.go new file mode 100644 index 0000000..a681e86 --- /dev/null +++ b/libgost/tun_darwin_test.go @@ -0,0 +1,13 @@ +//go:build darwin + +package libgost + +import "testing" + +func TestGetTunnelFileDescriptor(t *testing.T) { + // When no utun is active (typical unit test env), returns -1 without error. + fd := GetTunnelFileDescriptor() + if fd != -1 { + t.Logf("found existing utun fd: %d", fd) + } +} diff --git a/libgost/tun_stack_darwin.go b/libgost/tun_stack_darwin.go new file mode 100644 index 0000000..cfbd47e --- /dev/null +++ b/libgost/tun_stack_darwin.go @@ -0,0 +1,10 @@ +//go:build darwin + +package libgost + +// On macOS with NEPacketTunnelProvider, the "system" stack (kernel TCP +// with IP header rewriting) does not reliably deliver rewritten packets +// back to the kernel TCP stack on the point-to-point utun interface. +// sing-box also defaults to gvisor on macOS when includeAllNetworks is +// enabled. Use gvisor (userspace TCP via gVisor netstack) instead. +const tunStackType = "gvisor" diff --git a/libgost/tun_stack_default.go b/libgost/tun_stack_default.go new file mode 100644 index 0000000..17093bb --- /dev/null +++ b/libgost/tun_stack_default.go @@ -0,0 +1,8 @@ +//go:build !darwin + +package libgost + +// tunStackType selects the sing-tun stack implementation. +// The "system" stack is lightweight (kernel TCP + IP header rewriting) +// and works on Android, Linux, and Windows. +const tunStackType = "system" diff --git a/libgost/tun_test.go b/libgost/tun_test.go index 460f8f3..e4641bc 100644 --- a/libgost/tun_test.go +++ b/libgost/tun_test.go @@ -15,3 +15,4 @@ func TestStartTunInvalidFd(t *testing.T) { t.Fatal("StartTun(-1, 1500) should return an error") } } + diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..f6a5a67 Binary files /dev/null and b/logo.png differ diff --git a/macos/Configuration/DevelopmentTeam.example.xcconfig b/macos/Configuration/DevelopmentTeam.example.xcconfig new file mode 100644 index 0000000..d142e46 --- /dev/null +++ b/macos/Configuration/DevelopmentTeam.example.xcconfig @@ -0,0 +1,4 @@ +// macOS signing configuration — copy to DevelopmentTeam.xcconfig and fill in your Team ID +// Go to https://developer.apple.com/account to find your Team ID + +DEVELOPMENT_TEAM = YOUR_TEAM_ID diff --git a/macos/Configuration/ExportOptions.plist b/macos/Configuration/ExportOptions.plist new file mode 100644 index 0000000..055f67c --- /dev/null +++ b/macos/Configuration/ExportOptions.plist @@ -0,0 +1,8 @@ + + + + + method + developer-id + + diff --git a/macos/GostX.xcodeproj/project.pbxproj b/macos/GostX.xcodeproj/project.pbxproj index ca14373..36952da 100644 --- a/macos/GostX.xcodeproj/project.pbxproj +++ b/macos/GostX.xcodeproj/project.pbxproj @@ -3,11 +3,23 @@ archiveVersion = 1; classes = { }; - objectVersion = 55; + objectVersion = 70; objects = { - /* Begin PBXBuildFile section */ - B523FCA529E169180050578C /* HighlightedTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = B523FCA429E169180050578C /* HighlightedTextEditor */; }; + 0874FE9F0ADC49BBB6CD3626 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 296DECB2BD4347C19DFD902E /* PacketTunnelProvider.swift */; }; + 110C2C030865F24C8C297E45 /* LogOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0072E6B1705600E6B4FA5B /* LogOptionsView.swift */; }; + 112569258691CDCE54D5D03B /* LogContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19495E70527B5668EC230C85 /* LogContentView.swift */; }; + 2FA7674F8D9746F99910289A /* VpnManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F188A4B6A6F14F1D867F7D30 /* VpnManager.swift */; }; + 430349443004D3A000FD9EE9 /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 430349433004D3A000FD9EE9 /* NetworkExtension.framework */; }; + 4303494C3004D3A000FD9EE9 /* GostXTunnel.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 430349423004D3A000FD9EE9 /* GostXTunnel.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 4303495A3004D6B700FD9EE9 /* AppGroupConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430349593004D6B700FD9EE9 /* AppGroupConfig.swift */; }; + 4303495B3004D6D500FD9EE9 /* AppGroupConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430349593004D6B700FD9EE9 /* AppGroupConfig.swift */; }; + 43F7013E300838C000E70769 /* Libgost.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4303495C3004D78100FD9EE9 /* Libgost.xcframework */; }; + 43F70141300838DC00E70769 /* Libgost.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4303495C3004D78100FD9EE9 /* Libgost.xcframework */; }; + 539C284B9A34F4EEB926B517 /* FileListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC1099D8C22BC993593DE940 /* FileListView.swift */; }; + 5B773BD6DC67E0481B3E9F01 /* FileManageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B40B99520AC17D0C83EEFF5 /* FileManageViewModel.swift */; }; + 661952039FA5490CAD5BBB39 /* YamlEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CA10536E0954A81A09F6F24 /* YamlEditorView.swift */; }; + 80502C1365AFE6E9D0EBCE67 /* ProfileListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D241A5E3ABD1C9E90BA745A /* ProfileListView.swift */; }; B532A66A285B528900159BAC /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = B532A669285B528900159BAC /* main.swift */; }; B532A66E285B528D00159BAC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B532A66D285B528D00159BAC /* Assets.xcassets */; }; B532A671285B528D00159BAC /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B532A670285B528D00159BAC /* Preview Assets.xcassets */; }; @@ -17,11 +29,22 @@ B5AEDDB12864920800C09ED6 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5AEDDB02864920800C09ED6 /* AppDelegate.swift */; }; B5B0E074285B62B80035A6F2 /* MacExtrasConfigurator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B0E073285B62B80035A6F2 /* MacExtrasConfigurator.swift */; }; B5B0E07A285C63080035A6F2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B0E079285C63080035A6F2 /* SettingsView.swift */; }; + B5B1C1A329DD6E90008AA943 /* FileRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B1C1A429DD6E90008AA944 /* FileRepository.swift */; }; B5C1CF1429DD6E7C008AA934 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = B5C1CF1629DD6E7C008AA934 /* Localizable.stringsdict */; }; B5F6387929DEFF31009A1287 /* Arguments.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F6387829DEFF31009A1287 /* Arguments.swift */; }; + B949D5D298F19829F1C9EBAB /* LogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BFCA548038B9C1B568E78C0 /* LogViewModel.swift */; }; + BA165896763DA2BA6B8AE650 /* FileContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 041916CF79F25CAF1E21B685 /* FileContentView.swift */; }; + E086218C337C33C8BB4052CA /* ConfigRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D840A91D1C37F07D57AD8A1 /* ConfigRepository.swift */; }; + EBCBCEB114E34B4BB5D68150 /* AppLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16E9D6522B494B409C95F0FE /* AppLogger.swift */; }; /* End PBXBuildFile section */ - /* Begin PBXContainerItemProxy section */ + 4303494A3004D3A000FD9EE9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = B532A65E285B528900159BAC /* Project object */; + proxyType = 1; + remoteGlobalIDString = 430349413004D3A000FD9EE9; + remoteInfo = GostXTunnel; + }; B532A678285B528D00159BAC /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = B532A65E285B528900159BAC /* Project object */; @@ -37,8 +60,35 @@ remoteInfo = GostX; }; /* End PBXContainerItemProxy section */ - +/* Begin PBXCopyFilesBuildPhase section */ + 4303494D3004D3A000FD9EE9 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 4303494C3004D3A000FD9EE9 /* GostXTunnel.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 041916CF79F25CAF1E21B685 /* FileContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FileContentView.swift; sourceTree = ""; }; + 16E9D6522B494B409C95F0FE /* AppLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogger.swift; sourceTree = ""; }; + 19495E70527B5668EC230C85 /* LogContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LogContentView.swift; sourceTree = ""; }; + 1CA10536E0954A81A09F6F24 /* YamlEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YamlEditorView.swift; sourceTree = ""; }; + 1D241A5E3ABD1C9E90BA745A /* ProfileListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ProfileListView.swift; sourceTree = ""; }; + 296DECB2BD4347C19DFD902E /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PacketTunnelProvider.swift; path = GostXTunnel/PacketTunnelProvider.swift; sourceTree = ""; }; + 2F0072E6B1705600E6B4FA5B /* LogOptionsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LogOptionsView.swift; sourceTree = ""; }; + 430349423004D3A000FD9EE9 /* GostXTunnel.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = GostXTunnel.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 430349433004D3A000FD9EE9 /* NetworkExtension.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NetworkExtension.framework; path = System/Library/Frameworks/NetworkExtension.framework; sourceTree = SDKROOT; }; + 430349593004D6B700FD9EE9 /* AppGroupConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppGroupConfig.swift; path = GostX/AppGroupConfig.swift; sourceTree = ""; }; + 4303495C3004D78100FD9EE9 /* Libgost.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = Libgost.xcframework; path = Frameworks/Libgost.xcframework; sourceTree = ""; }; + 5D840A91D1C37F07D57AD8A1 /* ConfigRepository.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigRepository.swift; sourceTree = ""; }; + 6B40B99520AC17D0C83EEFF5 /* FileManageViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FileManageViewModel.swift; sourceTree = ""; }; + 8BFCA548038B9C1B568E78C0 /* LogViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LogViewModel.swift; sourceTree = ""; }; + AD12003E3005000000FD9EE9 /* DevelopmentTeam.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = DevelopmentTeam.xcconfig; sourceTree = ""; }; B532A666285B528900159BAC /* GostX.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GostX.app; sourceTree = BUILT_PRODUCTS_DIR; }; B532A669285B528900159BAC /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; B532A66D285B528D00159BAC /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -52,17 +102,40 @@ B5AEDDB02864920800C09ED6 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; B5B0E073285B62B80035A6F2 /* MacExtrasConfigurator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacExtrasConfigurator.swift; sourceTree = ""; }; B5B0E079285C63080035A6F2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + B5B1C1A429DD6E90008AA944 /* FileRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileRepository.swift; sourceTree = ""; }; B5C1CF1529DD6E7C008AA934 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = ""; }; B5C1CF1729DD6E90008AA934 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.stringsdict"; sourceTree = ""; }; B5F6387829DEFF31009A1287 /* Arguments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Arguments.swift; sourceTree = ""; }; + CC1099D8C22BC993593DE940 /* FileListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FileListView.swift; sourceTree = ""; }; + F188A4B6A6F14F1D867F7D30 /* VpnManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VpnManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ - +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 430349503004D3A000FD9EE9 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 430349413004D3A000FD9EE9 /* GostXTunnel */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 430349453004D3A000FD9EE9 /* GostXTunnel */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (430349503004D3A000FD9EE9 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = GostXTunnel; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 4303493F3004D3A000FD9EE9 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 43F7013E300838C000E70769 /* Libgost.xcframework in Frameworks */, + 430349443004D3A000FD9EE9 /* NetworkExtension.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; B532A663285B528900159BAC /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - B523FCA529E169180050578C /* HighlightedTextEditor in Frameworks */, + 43F70141300838DC00E70769 /* Libgost.xcframework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -81,16 +154,35 @@ runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ - /* Begin PBXGroup section */ + 43F701153007C22A00E70769 /* Recovered References */ = { + isa = PBXGroup; + children = ( + 296DECB2BD4347C19DFD902E /* PacketTunnelProvider.swift */, + ); + name = "Recovered References"; + sourceTree = ""; + }; + AD12003F3005000100FD9EE9 /* Configuration */ = { + isa = PBXGroup; + children = ( + AD12003E3005000000FD9EE9 /* DevelopmentTeam.xcconfig */, + ); + path = Configuration; + sourceTree = ""; + }; B532A65D285B528900159BAC = { isa = PBXGroup; children = ( + 430349593004D6B700FD9EE9 /* AppGroupConfig.swift */, + AD12003F3005000100FD9EE9 /* Configuration */, B532A668285B528900159BAC /* GostX */, B532A67A285B528D00159BAC /* GostXTests */, B532A684285B528D00159BAC /* GostXUITests */, + 430349453004D3A000FD9EE9 /* GostXTunnel */, B532A667285B528900159BAC /* Products */, B5AF722B28634A6500BB5F98 /* Frameworks */, + 43F701153007C22A00E70769 /* Recovered References */, ); sourceTree = ""; }; @@ -100,6 +192,7 @@ B532A666285B528900159BAC /* GostX.app */, B532A677285B528D00159BAC /* GostXTests.xctest */, B532A681285B528D00159BAC /* GostXUITests.xctest */, + 430349423004D3A000FD9EE9 /* GostXTunnel.appex */, ); name = Products; sourceTree = ""; @@ -113,9 +206,21 @@ B532A672285B528D00159BAC /* GostX.entitlements */, B532A66F285B528D00159BAC /* Preview Content */, B5B0E073285B62B80035A6F2 /* MacExtrasConfigurator.swift */, + F188A4B6A6F14F1D867F7D30 /* VpnManager.swift */, B5B0E079285C63080035A6F2 /* SettingsView.swift */, + 1CA10536E0954A81A09F6F24 /* YamlEditorView.swift */, + 16E9D6522B494B409C95F0FE /* AppLogger.swift */, B5AEDDB02864920800C09ED6 /* AppDelegate.swift */, B5F6387829DEFF31009A1287 /* Arguments.swift */, + B5B1C1A429DD6E90008AA944 /* FileRepository.swift */, + 5D840A91D1C37F07D57AD8A1 /* ConfigRepository.swift */, + 8BFCA548038B9C1B568E78C0 /* LogViewModel.swift */, + 2F0072E6B1705600E6B4FA5B /* LogOptionsView.swift */, + 19495E70527B5668EC230C85 /* LogContentView.swift */, + 1D241A5E3ABD1C9E90BA745A /* ProfileListView.swift */, + CC1099D8C22BC993593DE940 /* FileListView.swift */, + 041916CF79F25CAF1E21B685 /* FileContentView.swift */, + 6B40B99520AC17D0C83EEFF5 /* FileManageViewModel.swift */, ); path = GostX; sourceTree = ""; @@ -148,13 +253,34 @@ B5AF722B28634A6500BB5F98 /* Frameworks */ = { isa = PBXGroup; children = ( + 4303495C3004D78100FD9EE9 /* Libgost.xcframework */, + 430349433004D3A000FD9EE9 /* NetworkExtension.framework */, ); name = Frameworks; sourceTree = ""; }; /* End PBXGroup section */ - /* Begin PBXNativeTarget section */ + 430349413004D3A000FD9EE9 /* GostXTunnel */ = { + isa = PBXNativeTarget; + buildConfigurationList = 430349513004D3A000FD9EE9 /* Build configuration list for PBXNativeTarget "GostXTunnel" */; + buildPhases = ( + 4303493E3004D3A000FD9EE9 /* Sources */, + 4303493F3004D3A000FD9EE9 /* Frameworks */, + 430349403004D3A000FD9EE9 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 430349453004D3A000FD9EE9 /* GostXTunnel */, + ); + name = GostXTunnel; + productName = GostXTunnel; + productReference = 430349423004D3A000FD9EE9 /* GostXTunnel.appex */; + productType = "com.apple.product-type.app-extension"; + }; B532A665285B528900159BAC /* GostX */ = { isa = PBXNativeTarget; buildConfigurationList = B532A68B285B528D00159BAC /* Build configuration list for PBXNativeTarget "GostX" */; @@ -162,15 +288,14 @@ B532A662285B528900159BAC /* Sources */, B532A663285B528900159BAC /* Frameworks */, B532A664285B528900159BAC /* Resources */, + 4303494D3004D3A000FD9EE9 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 4303494B3004D3A000FD9EE9 /* PBXTargetDependency */, ); name = GostX; - packageProductDependencies = ( - B523FCA429E169180050578C /* HighlightedTextEditor */, - ); productName = GostX; productReference = B532A666285B528900159BAC /* GostX.app */; productType = "com.apple.product-type.application"; @@ -212,15 +337,17 @@ productType = "com.apple.product-type.bundle.ui-testing"; }; /* End PBXNativeTarget section */ - /* Begin PBXProject section */ B532A65E285B528900159BAC /* Project object */ = { isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1340; - LastUpgradeCheck = 1430; + LastSwiftUpdateCheck = 2650; + LastUpgradeCheck = 2650; TargetAttributes = { + 430349413004D3A000FD9EE9 = { + CreatedOnToolsVersion = 26.5; + }; B532A665285B528900159BAC = { CreatedOnToolsVersion = 13.4.1; }; @@ -244,9 +371,6 @@ "zh-Hans", ); mainGroup = B532A65D285B528900159BAC; - packageReferences = ( - B523FCA329E169180050578C /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */, - ); productRefGroup = B532A667285B528900159BAC /* Products */; projectDirPath = ""; projectRoot = ""; @@ -254,11 +378,18 @@ B532A665285B528900159BAC /* GostX */, B532A676285B528D00159BAC /* GostXTests */, B532A680285B528D00159BAC /* GostXUITests */, + 430349413004D3A000FD9EE9 /* GostXTunnel */, ); }; /* End PBXProject section */ - /* Begin PBXResourcesBuildPhase section */ + 430349403004D3A000FD9EE9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; B532A664285B528900159BAC /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -284,17 +415,38 @@ runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ - /* Begin PBXSourcesBuildPhase section */ + 4303493E3004D3A000FD9EE9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4303495B3004D6D500FD9EE9 /* AppGroupConfig.swift in Sources */, + 0874FE9F0ADC49BBB6CD3626 /* PacketTunnelProvider.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; B532A662285B528900159BAC /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4303495A3004D6B700FD9EE9 /* AppGroupConfig.swift in Sources */, B5F6387929DEFF31009A1287 /* Arguments.swift in Sources */, B5AEDDB12864920800C09ED6 /* AppDelegate.swift in Sources */, B5B0E07A285C63080035A6F2 /* SettingsView.swift in Sources */, + 661952039FA5490CAD5BBB39 /* YamlEditorView.swift in Sources */, + EBCBCEB114E34B4BB5D68150 /* AppLogger.swift in Sources */, + B5B1C1A329DD6E90008AA943 /* FileRepository.swift in Sources */, B5B0E074285B62B80035A6F2 /* MacExtrasConfigurator.swift in Sources */, + 2FA7674F8D9746F99910289A /* VpnManager.swift in Sources */, B532A66A285B528900159BAC /* main.swift in Sources */, + E086218C337C33C8BB4052CA /* ConfigRepository.swift in Sources */, + B949D5D298F19829F1C9EBAB /* LogViewModel.swift in Sources */, + 110C2C030865F24C8C297E45 /* LogOptionsView.swift in Sources */, + 112569258691CDCE54D5D03B /* LogContentView.swift in Sources */, + 80502C1365AFE6E9D0EBCE67 /* ProfileListView.swift in Sources */, + 539C284B9A34F4EEB926B517 /* FileListView.swift in Sources */, + BA165896763DA2BA6B8AE650 /* FileContentView.swift in Sources */, + 5B773BD6DC67E0481B3E9F01 /* FileManageViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -316,8 +468,12 @@ runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ - /* Begin PBXTargetDependency section */ + 4303494B3004D3A000FD9EE9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 430349413004D3A000FD9EE9 /* GostXTunnel */; + targetProxy = 4303494A3004D3A000FD9EE9 /* PBXContainerItemProxy */; + }; B532A679285B528D00159BAC /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = B532A665285B528900159BAC /* GostX */; @@ -329,7 +485,6 @@ targetProxy = B532A682285B528D00159BAC /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ - /* Begin PBXVariantGroup section */ B5C1CF1629DD6E7C008AA934 /* Localizable.stringsdict */ = { isa = PBXVariantGroup; @@ -341,8 +496,84 @@ sourceTree = ""; }; /* End PBXVariantGroup section */ - /* Begin XCBuildConfiguration section */ + 4303494E3004D3A000FD9EE9 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AD12003E3005000000FD9EE9 /* DevelopmentTeam.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = GostXTunnel/GostXTunnel.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = GostXTunnel/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "GostX Tunnel"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.5; + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = "-lresolv"; + PRODUCT_BUNDLE_IDENTIFIER = cn.liukebin.gostx.tunnel; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 4303494F3004D3A000FD9EE9 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AD12003E3005000000FD9EE9 /* DevelopmentTeam.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = GostXTunnel/GostXTunnel.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = GostXTunnel/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "GostX Tunnel"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.5; + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = "-lresolv"; + PRODUCT_BUNDLE_IDENTIFIER = cn.liukebin.gostx.tunnel; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; B532A689285B528D00159BAC /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -400,6 +631,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -451,10 +683,12 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; + EXCLUDED_ARCHS = x86_64; MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; }; @@ -462,17 +696,17 @@ }; B532A68C285B528D00159BAC /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = AD12003E3005000000FD9EE9 /* DevelopmentTeam.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = GostX/GostX.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"GostX/Preview Content\""; - DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -486,15 +720,12 @@ "$(inherited)", "@executable_path/../Frameworks", ); - LIBRARY_SEARCH_PATHS = "$(SRCROOT)/../go/"; MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 1.0.0; - OTHER_LDFLAGS = ( - "-lgost", - "-lresolv", - ); + OTHER_LDFLAGS = "-lresolv"; PRODUCT_BUNDLE_IDENTIFIER = cn.liukebin.gostx; PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_INCLUDE_PATHS = "$(SRCROOT)/../go/"; SWIFT_VERSION = 5.0; @@ -503,19 +734,18 @@ }; B532A68D285B528D00159BAC /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = AD12003E3005000000FD9EE9 /* DevelopmentTeam.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = GostX/GostX.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"GostX/Preview Content\""; - DEVELOPMENT_TEAM = ""; - ENABLE_HARDENED_RUNTIME = NO; + ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = GostX/Info.plist; @@ -528,15 +758,12 @@ "$(inherited)", "@executable_path/../Frameworks", ); - LIBRARY_SEARCH_PATHS = "$(SRCROOT)/../go/"; MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 1.0.0; - OTHER_LDFLAGS = ( - "-lgost", - "-lresolv", - ); + OTHER_LDFLAGS = "-lresolv"; PRODUCT_BUNDLE_IDENTIFIER = cn.liukebin.gostx; PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_INCLUDE_PATHS = "$(SRCROOT)/../go/"; SWIFT_VERSION = 5.0; @@ -546,7 +773,6 @@ B532A68F285B528D00159BAC /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; @@ -567,7 +793,6 @@ B532A690285B528D00159BAC /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -587,7 +812,6 @@ B532A692285B528D00159BAC /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; @@ -605,7 +829,6 @@ B532A693285B528D00159BAC /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; @@ -621,8 +844,16 @@ name = Release; }; /* End XCBuildConfiguration section */ - /* Begin XCConfigurationList section */ + 430349513004D3A000FD9EE9 /* Build configuration list for PBXNativeTarget "GostXTunnel" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4303494E3004D3A000FD9EE9 /* Debug */, + 4303494F3004D3A000FD9EE9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; B532A661285B528900159BAC /* Build configuration list for PBXProject "GostX" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -660,25 +891,6 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ - -/* Begin XCRemoteSwiftPackageReference section */ - B523FCA329E169180050578C /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/kyle-n/HighlightedTextEditor"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.0.0; - }; - }; -/* End XCRemoteSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - B523FCA429E169180050578C /* HighlightedTextEditor */ = { - isa = XCSwiftPackageProductDependency; - package = B523FCA329E169180050578C /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */; - productName = HighlightedTextEditor; - }; -/* End XCSwiftPackageProductDependency section */ }; rootObject = B532A65E285B528900159BAC /* Project object */; } diff --git a/macos/GostX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/macos/GostX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index f8a3fd7..0000000 --- a/macos/GostX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,14 +0,0 @@ -{ - "pins" : [ - { - "identity" : "highlightedtexteditor", - "kind" : "remoteSourceControl", - "location" : "https://github.com/kyle-n/HighlightedTextEditor", - "state" : { - "revision" : "7069ede002afc6bde867aef7035f45ff72dc839c", - "version" : "2.1.0" - } - } - ], - "version" : 2 -} diff --git a/macos/GostX.xcodeproj/xcshareddata/xcschemes/GostX.xcscheme b/macos/GostX.xcodeproj/xcshareddata/xcschemes/GostX.xcscheme index 862b3b6..9f272db 100644 --- a/macos/GostX.xcodeproj/xcshareddata/xcschemes/GostX.xcscheme +++ b/macos/GostX.xcodeproj/xcshareddata/xcschemes/GostX.xcscheme @@ -1,6 +1,6 @@ Bool { NSApp.setActivationPolicy(.accessory) - return false } - + func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { return true } - + func quit() { NSApp.terminate(self) } - + func stop() { - gostStop() - self.menu?.updateListen(nil) + AppLogger.log(.info, "Stopping...") + if vpnMode { + Task { @MainActor in VpnManager.shared.stop() } + } else { + LibgostStopGost(nil) + } self.menu?.toOffState() + AppLogger.log(.info, "Stopped") } - - func start() { - let yaml = UserDefaults.standard.string(forKey: defaultsArgumentsKey) ?? defaultGostYAML - var fd = self.logPipe?.fileHandleForWriting.fileDescriptor - let fdPtr = UnsafeMutablePointer.allocate(capacity: 1) - withUnsafeMutablePointer(to: &fd) { ptr in - fdPtr.initialize(to: CLong(ptr.pointee!)) + func start() { + AppLogger.log(.info, "Starting...") + if vpnMode { + startVpnMode() + } else { + startProxyMode() } + } - let isFailed = gostRunYaml( - UnsafeMutablePointer(mutating: (yaml as NSString).utf8String), - UnsafeMutablePointer(mutating: fdPtr) - ) + // MARK: - VPN Mode - if isFailed != 0 { - self.menu?.toOffState() - stop() - return - } + private func startVpnMode() { + AppLogger.log(.info, "Starting VPN mode") + // 同步 YAML 到 App Group + let yaml = UserDefaults.standard.string(forKey: defaultsYamlKey) ?? defaultGostYAML + AppGroupConfig.writeYaml(yaml) - if let infoPtr = gostInfo() { - let statusJSON = String(cString: infoPtr.pointee.status_json) - self.menu?.updateListen(statusJSON) - infoPtr.deallocate() + Task { + do { + try await VpnManager.shared.start() + DispatchQueue.main.async { self.menu?.toOnState() } + AppLogger.log(.info, "VPN started") + } catch { + logger.error("VPN start failed: \(error.localizedDescription)") + AppLogger.log(.error, "VPN start failed: \(error.localizedDescription)") + DispatchQueue.main.async { self.menu?.toOffState() } + } } - self.menu?.toOnState() } - - private func pipe() -> Pipe { - let p = Pipe() - p.fileHandleForReading.readabilityHandler = { handle in - let data = handle.availableData - if data.count == 0 { - // No data available means EOF; we must unregister ourselves - // in order to not immediately be called again. - handle.readabilityHandler = nil - return - } - guard let str = String(data: data, encoding: .utf8) else { - // Non-UTF-8 data from Syncthing should never happen. - return - } + // MARK: - Proxy Mode (non-VPN) + + private func startProxyMode() { + AppLogger.log(.info, "Starting proxy mode") + let yaml = UserDefaults.standard.string(forKey: defaultsYamlKey) ?? defaultGostYAML + + // Set work dir to App Group container so gost can find imported bypass files + if let containerURL = AppGroupConfig.containerURL { + let filesDir = containerURL.appendingPathComponent("files") + try? FileManager.default.createDirectory(at: filesDir, + withIntermediateDirectories: true, attributes: nil) + LibgostSetWorkDir(filesDir.path, nil) + } - logger.log("\(str, privacy: .public)") + var err: NSError? + if !LibgostStartGost(yaml, "", &err), let err { + logger.error("gost start failed: \(err.localizedDescription)") + AppLogger.log(.error, "gost start failed: \(err.localizedDescription)") + self.menu?.toOffState() + return } - return p + + self.menu?.toOnState() + AppLogger.log(.info, "Proxy started") } } diff --git a/macos/GostX/AppGroupConfig.swift b/macos/GostX/AppGroupConfig.swift new file mode 100644 index 0000000..9cbc37d --- /dev/null +++ b/macos/GostX/AppGroupConfig.swift @@ -0,0 +1,50 @@ +// macos/GostX/AppGroupConfig.swift +import Foundation + +/// 通过 App Group 容器在主 App 和 Extension 之间共享 YAML 配置和日志设置。 +struct AppGroupConfig { + static let groupId = "group.cn.liukebin.gostx" + static let yamlFileName = "gost.yaml" + + static var containerURL: URL? { + FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: groupId + ) + } + + static var yamlFileURL: URL? { + containerURL?.appendingPathComponent(yamlFileName) + } + + /// 从 App Group 容器读取 YAML 配置字符串。 + /// 返回 nil 表示容器不可用或文件不存在。 + static func readYaml() -> String? { + guard let url = yamlFileURL else { return nil } + return try? String(contentsOf: url, encoding: .utf8) + } + + /// 将 YAML 配置写入 App Group 容器。 + /// 失败静默忽略(Extension 启动时会检查并报错)。 + static func writeYaml(_ yaml: String) { + guard let url = yamlFileURL else { return } + try? yaml.write(to: url, atomically: true, encoding: .utf8) + } + + // MARK: - Logging Settings + + private static var sharedDefaults: UserDefaults? { + UserDefaults(suiteName: groupId) + } + + static var loggingEnabled: Bool { + get { sharedDefaults?.bool(forKey: "logging_enabled") ?? true } + set { sharedDefaults?.set(newValue, forKey: "logging_enabled") } + } + + static var logLevel: String { + get { sharedDefaults?.string(forKey: "log_level") ?? "info" } + set { sharedDefaults?.set(newValue, forKey: "log_level") } + } + + static let logLevelOptions = ["error", "warn", "info", "debug", "trace"] +} diff --git a/macos/GostX/AppLogger.swift b/macos/GostX/AppLogger.swift new file mode 100644 index 0000000..4cbccd7 --- /dev/null +++ b/macos/GostX/AppLogger.swift @@ -0,0 +1,50 @@ +// +// AppLogger.swift +// GostX +// +// Bridges main app os_log to gost.log for unified logging. +// + +import Foundation +import os + +/// Writes log messages from the main app into the shared gost.log file +/// alongside the tunnel extension logs. +enum AppLogger { + private static let queue = DispatchQueue(label: "cn.liukebin.gostx.applogger") + private static var logFileURL: URL? + + /// Must be called once on app startup with the container URL. + static func configure(containerURL: URL) { + logFileURL = containerURL.appendingPathComponent("gost.log") + } + + static func log(_ level: OSLogType = .default, _ message: String) { + os_log(level, "%{public}@", message) + guard AppGroupConfig.loggingEnabled else { return } + writeToFile("[APP] \(message)") + } + + private static func writeToFile(_ line: String) { + guard let url = logFileURL else { return } + queue.async { + let timestamp = ISO8601DateFormatter().string(from: Date()) + let entry = "\(formatTimestamp()) \(line)\n" + if let data = entry.data(using: .utf8) { + if let handle = try? FileHandle(forWritingTo: url) { + handle.seekToEndOfFile() + handle.write(data) + try? handle.close() + } else { + try? data.write(to: url, options: .atomic) + } + } + } + } + + private static func formatTimestamp() -> String { + let df = DateFormatter() + df.dateFormat = "HH:mm:ss.SSS" + return df.string(from: Date()) + } +} diff --git a/macos/GostX/Arguments.swift b/macos/GostX/Arguments.swift index f9e34d7..28ba405 100644 --- a/macos/GostX/Arguments.swift +++ b/macos/GostX/Arguments.swift @@ -13,3 +13,12 @@ services: listener: type: tcp """ + +// YAML 持久化 key(UserDefaults) +let defaultsYamlKey = "gost_yaml_config" + +// VPN 模式 key +let defaultsVpnModeKey = "gost_vpn_mode_enabled" + +// App Group 容器目录下 YAML 文件名 +let appGroupYamlFileName = "gost.yaml" diff --git a/macos/GostX/Assets.xcassets/AppIcon.appiconset/1024.png b/macos/GostX/Assets.xcassets/AppIcon.appiconset/1024.png index db96288..f6a5a67 100644 Binary files a/macos/GostX/Assets.xcassets/AppIcon.appiconset/1024.png and b/macos/GostX/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/macos/GostX/Assets.xcassets/AppIcon.appiconset/128.png b/macos/GostX/Assets.xcassets/AppIcon.appiconset/128.png index 33d5336..4e8eaf9 100644 Binary files a/macos/GostX/Assets.xcassets/AppIcon.appiconset/128.png and b/macos/GostX/Assets.xcassets/AppIcon.appiconset/128.png differ diff --git a/macos/GostX/Assets.xcassets/AppIcon.appiconset/16.png b/macos/GostX/Assets.xcassets/AppIcon.appiconset/16.png index 5082b1f..ab5a6b2 100644 Binary files a/macos/GostX/Assets.xcassets/AppIcon.appiconset/16.png and b/macos/GostX/Assets.xcassets/AppIcon.appiconset/16.png differ diff --git a/macos/GostX/Assets.xcassets/AppIcon.appiconset/256.png b/macos/GostX/Assets.xcassets/AppIcon.appiconset/256.png index 763203e..c89be45 100644 Binary files a/macos/GostX/Assets.xcassets/AppIcon.appiconset/256.png and b/macos/GostX/Assets.xcassets/AppIcon.appiconset/256.png differ diff --git a/macos/GostX/Assets.xcassets/AppIcon.appiconset/32.png b/macos/GostX/Assets.xcassets/AppIcon.appiconset/32.png index d66cf92..e2b2260 100644 Binary files a/macos/GostX/Assets.xcassets/AppIcon.appiconset/32.png and b/macos/GostX/Assets.xcassets/AppIcon.appiconset/32.png differ diff --git a/macos/GostX/Assets.xcassets/AppIcon.appiconset/512.png b/macos/GostX/Assets.xcassets/AppIcon.appiconset/512.png index d215293..31215f6 100644 Binary files a/macos/GostX/Assets.xcassets/AppIcon.appiconset/512.png and b/macos/GostX/Assets.xcassets/AppIcon.appiconset/512.png differ diff --git a/macos/GostX/Assets.xcassets/AppIcon.appiconset/64.png b/macos/GostX/Assets.xcassets/AppIcon.appiconset/64.png index 8b12ce2..5372a88 100644 Binary files a/macos/GostX/Assets.xcassets/AppIcon.appiconset/64.png and b/macos/GostX/Assets.xcassets/AppIcon.appiconset/64.png differ diff --git a/macos/GostX/ConfigRepository.swift b/macos/GostX/ConfigRepository.swift new file mode 100644 index 0000000..183a873 --- /dev/null +++ b/macos/GostX/ConfigRepository.swift @@ -0,0 +1,130 @@ +// macos/GostX/ConfigRepository.swift +import Foundation + +// MARK: - Profile Model + +struct Profile: Identifiable, Codable { + var id: String + var name: String + private var config: String + + init(id: String = UUID().uuidString, name: String, config: String = defaultGostYAML) { + self.id = id + self.name = name + self.config = config + } + + var yamlConfig: String { + get { config } + set { config = newValue } + } +} + +// MARK: - ConfigRepository + +class ConfigRepository: ObservableObject { + static let shared = ConfigRepository() + + private let defaults = UserDefaults.standard + private let profilesKey = "gost_profiles_v2" + private let activeProfileKey = "gost_active_profile_id" + + @Published var profiles: [Profile] = [] + @Published var activeProfileId: String? + + private init() { + load() + ensureDefaultProfile() + } + + // MARK: - Persistence + + private func load() { + guard let data = defaults.data(forKey: profilesKey), + let decoded = try? JSONDecoder().decode([Profile].self, from: data) + else { return } + profiles = decoded + activeProfileId = defaults.string(forKey: activeProfileKey) + } + + private func save() { + if let data = try? JSONEncoder().encode(profiles) { + defaults.set(data, forKey: profilesKey) + } + defaults.set(activeProfileId, forKey: activeProfileKey) + } + + private func ensureDefaultProfile() { + guard profiles.isEmpty else { return } + let defaultProfile = Profile(name: "Default", config: defaultGostYAML) + profiles = [defaultProfile] + activeProfileId = defaultProfile.id + save() + } + + // MARK: - Profile CRUD + + @discardableResult + func addProfile(name: String) -> String? { + let trimmed = name.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return nil } + let profile = Profile(name: trimmed) + profiles.append(profile) + save() + return profile.id + } + + func deleteProfile(_ id: String) { + profiles.removeAll { $0.id == id } + if activeProfileId == id { + activeProfileId = profiles.first?.id + syncActiveToAppGroup() + } + save() + } + + func renameProfile(_ id: String, newName: String) -> Bool { + let trimmed = newName.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, + let index = profiles.firstIndex(where: { $0.id == id }) + else { return false } + profiles[index].name = trimmed + save() + return true + } + + // MARK: - Config + + func getConfig(_ profileId: String) -> String { + profiles.first(where: { $0.id == profileId })?.yamlConfig ?? defaultGostYAML + } + + func saveConfig(_ profileId: String, yaml: String) { + guard let index = profiles.firstIndex(where: { $0.id == profileId }) else { return } + profiles[index].yamlConfig = yaml + save() + if profileId == activeProfileId { + AppGroupConfig.writeYaml(yaml) + } + } + + // MARK: - Active Profile + + func setActiveProfile(_ id: String) { + guard profiles.contains(where: { $0.id == id }) else { return } + activeProfileId = id + save() + syncActiveToAppGroup() + } + + var activeConfig: String { + guard let id = activeProfileId, + let profile = profiles.first(where: { $0.id == id }) + else { return defaultGostYAML } + return profile.yamlConfig + } + + private func syncActiveToAppGroup() { + AppGroupConfig.writeYaml(activeConfig) + } +} diff --git a/macos/GostX/FileContentView.swift b/macos/GostX/FileContentView.swift new file mode 100644 index 0000000..0d47d73 --- /dev/null +++ b/macos/GostX/FileContentView.swift @@ -0,0 +1,48 @@ +// macos/GostX/FileContentView.swift +import SwiftUI + +@available(macOS 14.0, *) +struct FileContentView: View { + @ObservedObject var vm: FileManageViewModel + + var body: some View { + Group { + if vm.selectedFileName != nil { + VStack(spacing: 0) { + TextEditor(text: $vm.fileContent) + .font(.system(size: 12, design: .monospaced)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onChange(of: vm.fileContent) { _ in + DispatchQueue.main.async { + vm.isFileDirty = vm.fileContent != vm.originalContent + } + } + + Divider() + + HStack { + Spacer() + Button(action: { vm.saveFileContent() }) { + Label(NSLocalizedString("Save", comment: ""), systemImage: "square.and.arrow.down") + } + .buttonStyle(.borderless) + .disabled(!vm.isFileDirty) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(height: 32) + } + } else { + VStack { + Image(systemName: "doc.text.magnifyingglass") + .font(.system(size: 28)) + .foregroundColor(Color(nsColor: .tertiaryLabelColor)) + Text(NSLocalizedString("Select a file to view", comment: "")) + .font(.system(size: 13)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } +} diff --git a/macos/GostX/FileListView.swift b/macos/GostX/FileListView.swift new file mode 100644 index 0000000..8bc21db --- /dev/null +++ b/macos/GostX/FileListView.swift @@ -0,0 +1,326 @@ +// macos/GostX/FileListView.swift +import SwiftUI +import AppKit + +// MARK: - Helpers + +private func formatFileSize(_ bytes: Int64) -> String { + switch bytes { + case 0..<1024: return "\(bytes) B" + case 1024..<(1024 * 1024): return "\(bytes / 1024) KB" + default: return String(format: "%.1f MB", Double(bytes) / (1024.0 * 1024.0)) + } +} + +private func formatFileDate(_ date: Date) -> String { + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM-dd HH:mm" + return fmt.string(from: date) +} + +// MARK: - FileListView + +@available(macOS 14.0, *) +struct FileListView: View { + @ObservedObject var vm: FileManageViewModel + @State private var showImporter = false + @State private var showExporter = false + @State private var exportName: String? + @State private var renameTarget: FileInfo? + @State private var renameText: String = "" + @State private var deleteTarget: FileInfo? + @State private var showDeleteConfirm = false + @State private var showNewFileSheet = false + @State private var newFileName = "" + + var body: some View { + if !vm.isAvailable { + unavailableView + } else { + listContent + } + } + + private var unavailableView: some View { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 32)) + .foregroundColor(.secondary) + Text(NSLocalizedString("App Group container not available.", comment: "")) + .font(.system(size: 13)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var listContent: some View { + VStack(spacing: 0) { + if vm.files.isEmpty { + VStack(spacing: 8) { + Spacer() + Text(NSLocalizedString("No files", comment: "")) + .font(.system(size: 12)) + .foregroundColor(.secondary) + Spacer() + } + } else { + List(selection: Binding( + get: { vm.selectedFileName }, + set: { name in + DispatchQueue.main.async { + if let name { + vm.selectFile(name) + } else { + vm.selectedFileName = nil + vm.fileContent = "" + } + } + } + )) { + ForEach(vm.files) { file in + FileRowView( + file: file, + onExport: { + exportName = file.name + showExporter = true + }, + onRename: { + renameTarget = file + renameText = file.name + }, + onCopyPath: { vm.copyPath(file.name) }, + onDelete: { + deleteTarget = file + showDeleteConfirm = true + } + ) + .tag(file.name) + } + } + .listStyle(.automatic) + .padding(.horizontal, 4) + } + + Divider() + + HStack { + Button(action: { showNewFileSheet = true }) { + Label(NSLocalizedString("New File", comment: ""), systemImage: "doc.badge.plus") + } + .buttonStyle(.borderless) + Spacer() + Button(action: { showImporter = true }) { + Label(NSLocalizedString("Import", comment: ""), systemImage: "square.and.arrow.up") + } + .buttonStyle(.borderless) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(height: 32) + } + .fileImporter(isPresented: $showImporter, allowedContentTypes: [.data, .plainText, .text]) { result in + if case .success(let url) = result { + vm.importFile(from: url) + } + } + .fileExporter( + isPresented: $showExporter, + item: exportName ?? "", + contentTypes: [.data], + defaultFilename: exportName ?? "file" + ) { result in + if case .success(let url) = result, let name = exportName { + vm.exportFile(name, to: url) + } + exportName = nil + } + .sheet(isPresented: $showNewFileSheet) { + VStack(spacing: 16) { + Text(NSLocalizedString("New File", comment: "")).font(.headline) + TextField(NSLocalizedString("File name", comment: ""), text: $newFileName) + .textFieldStyle(.roundedBorder) + .frame(width: 250) + HStack { + Button(NSLocalizedString("Cancel", comment: "")) { + showNewFileSheet = false + newFileName = "" + } + .keyboardShortcut(.cancelAction) + Button(NSLocalizedString("Create", comment: "")) { + vm.createFile(newFileName) + newFileName = "" + showNewFileSheet = false + } + .keyboardShortcut(.defaultAction) + .disabled(newFileName.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .padding() + .frame(width: 300, height: 140) + } + .modifier(FileManageAlertModifier(vm: vm)) + .modifier(OverwriteSheetModifier(vm: vm)) + .modifier(FileManageDialogsModifier( + renameTarget: $renameTarget, + renameText: $renameText, + deleteTarget: $deleteTarget, + showDeleteConfirm: $showDeleteConfirm, + vm: vm + )) + } +} + +// MARK: - FileRowView + +@available(macOS 14.0, *) +private struct FileRowView: View { + let file: FileInfo + let onExport: () -> Void + let onRename: () -> Void + let onCopyPath: () -> Void + let onDelete: () -> Void + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "doc.text") + .font(.system(size: 16)) + .foregroundColor(.secondary) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 1) { + Text(file.name) + .font(.system(size: 13)) + .lineLimit(1) + Text(formatFileSize(file.sizeBytes)) + .font(.system(size: 10)) + .foregroundColor(.secondary) + Text(formatFileDate(file.lastModified)) + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + + Spacer() + } + .padding(.vertical, 4) + .contextMenu { + Button(NSLocalizedString("Export...", comment: "")) { onExport() } + Button(NSLocalizedString("Rename...", comment: "")) { onRename() } + Button(NSLocalizedString("Copy Path", comment: "")) { onCopyPath() } + Divider() + Button(NSLocalizedString("Delete...", comment: ""), role: .destructive) { onDelete() } + } + } +} + +// MARK: - Alert Modifier + +@available(macOS 14.0, *) +private struct FileManageAlertModifier: ViewModifier { + @ObservedObject var vm: FileManageViewModel + + func body(content: Content) -> some View { + content.alert( + NSLocalizedString("Error", comment: ""), + isPresented: Binding( + get: { vm.alertMessage != nil }, + set: { if !$0 { vm.alertMessage = nil } } + ), + actions: { + Button(NSLocalizedString("OK", comment: "")) { vm.alertMessage = nil } + }, + message: { + Text(vm.alertMessage ?? "") + } + ) + } +} + +// MARK: - Overwrite Sheet Modifier + +@available(macOS 14.0, *) +private struct OverwriteSheetModifier: ViewModifier { + @ObservedObject var vm: FileManageViewModel + + func body(content: Content) -> some View { + content.sheet(isPresented: Binding( + get: { vm.pendingOverwrite != nil }, + set: { if !$0 { vm.cancelOverwrite() } } + )) { + VStack(spacing: 16) { + Text(NSLocalizedString("File Already Exists", comment: "")) + .font(.headline) + Text(String.localizedStringWithFormat( + NSLocalizedString("A file named \"%@\" already exists. Do you want to replace it?", comment: ""), + vm.pendingOverwrite?.fileName ?? "")) + .font(.system(size: 13)) + .multilineTextAlignment(.center) + .frame(width: 300) + HStack(spacing: 12) { + Button(NSLocalizedString("Cancel", comment: "")) { vm.cancelOverwrite() } + .keyboardShortcut(.cancelAction) + Button(NSLocalizedString("Replace", comment: "")) { vm.confirmOverwrite() } + .keyboardShortcut(.defaultAction) + } + } + .padding() + .frame(width: 340, height: 160) + } + } +} + +// MARK: - Rename / Delete Dialogs Modifier + +@available(macOS 14.0, *) +private struct FileManageDialogsModifier: ViewModifier { + @Binding var renameTarget: FileInfo? + @Binding var renameText: String + @Binding var deleteTarget: FileInfo? + @Binding var showDeleteConfirm: Bool + let vm: FileManageViewModel + + func body(content: Content) -> some View { + content + .sheet(isPresented: Binding( + get: { renameTarget != nil }, + set: { if !$0 { renameTarget = nil } } + )) { + VStack(spacing: 16) { + Text(NSLocalizedString("Rename File", comment: "")).font(.headline) + TextField(NSLocalizedString("File name", comment: ""), text: $renameText) + .textFieldStyle(.roundedBorder) + .frame(width: 250) + HStack { + Button(NSLocalizedString("Cancel", comment: "")) { renameTarget = nil } + .keyboardShortcut(.cancelAction) + Button(NSLocalizedString("Rename", comment: "")) { + if let target = renameTarget, !renameText.trimmingCharacters(in: .whitespaces).isEmpty { + vm.renameFile(target.name, to: renameText) + renameTarget = nil + } + } + .keyboardShortcut(.defaultAction) + .disabled(renameText.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .padding() + .frame(width: 320, height: 150) + } + .alert( + NSLocalizedString("Delete File", comment: ""), + isPresented: $showDeleteConfirm, + presenting: deleteTarget + ) { file in + Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) { + deleteTarget = nil + } + Button(NSLocalizedString("Delete", comment: ""), role: .destructive) { + vm.deleteFile(file.name) + deleteTarget = nil + } + } message: { file in + Text(String.localizedStringWithFormat( + NSLocalizedString("Are you sure you want to delete \"%@\"? This cannot be undone.", comment: ""), + file.name)) + } + } +} diff --git a/macos/GostX/FileManageViewModel.swift b/macos/GostX/FileManageViewModel.swift new file mode 100644 index 0000000..46fdd7c --- /dev/null +++ b/macos/GostX/FileManageViewModel.swift @@ -0,0 +1,133 @@ +// macos/GostX/FileManageViewModel.swift +import SwiftUI +import AppKit + +@MainActor +class FileManageViewModel: ObservableObject { + @Published var files: [FileInfo] = [] + @Published var alertMessage: String? + @Published var pendingOverwrite: (sourceURL: URL, fileName: String)? + @Published var selectedFileName: String? + @Published var fileContent: String = "" + @Published var isFileDirty: Bool = false + var originalContent: String = "" + + private let repo: FileRepository? + + init() { + repo = FileRepository() + refresh() + } + + var isAvailable: Bool { repo != nil } + + func refresh() { + files = repo?.listFiles() ?? [] + } + + func selectFile(_ name: String) { + selectedFileName = name + let content = repo?.readFileContent(name) ?? "" + originalContent = content + fileContent = content + isFileDirty = false + } + + func saveFileContent() { + guard let repo, let name = selectedFileName else { return } + do { + try repo.writeFileContent(name, content: fileContent) + originalContent = fileContent + isFileDirty = false + refresh() + } catch { + alertMessage = error.localizedDescription + } + } + + func createFile(_ name: String) { + guard let repo else { return } + do { + try repo.createFile(name) + refresh() + } catch { + alertMessage = error.localizedDescription + } + } + + func importFile(from sourceURL: URL) { + guard let repo else { return } + let name = sourceURL.lastPathComponent + if repo.exists(name) { + pendingOverwrite = (sourceURL, name) + return + } + doImport(sourceURL: sourceURL) + } + + func confirmOverwrite() { + guard let (url, _) = pendingOverwrite else { return } + pendingOverwrite = nil + doImport(sourceURL: url) + } + + func cancelOverwrite() { + pendingOverwrite = nil + } + + private func doImport(sourceURL: URL) { + guard let repo else { return } + do { + try repo.importFile(from: sourceURL) + refresh() + } catch { + alertMessage = String.localizedStringWithFormat( + NSLocalizedString("Import failed: %@", comment: ""), + error.localizedDescription) + } + } + + func renameFile(_ oldName: String, to newName: String) { + guard let repo else { return } + do { + try repo.renameFile(oldName, to: newName) + if selectedFileName == oldName { + selectedFileName = newName + fileContent = repo.readFileContent(newName) ?? "" + } + refresh() + } catch { + alertMessage = error.localizedDescription + } + } + + func deleteFile(_ name: String) { + guard let repo else { return } + do { + try repo.deleteFile(name) + if selectedFileName == name { + selectedFileName = nil + fileContent = "" + } + refresh() + } catch { + alertMessage = error.localizedDescription + } + } + + func exportFile(_ name: String, to destURL: URL) { + guard let repo else { return } + do { + try repo.exportFile(name, to: destURL) + } catch { + alertMessage = error.localizedDescription + } + } + + func copyPath(_ name: String) { + guard let repo else { return } + let path = repo.filePath(name) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(path, forType: .string) + } +} diff --git a/macos/GostX/FileRepository.swift b/macos/GostX/FileRepository.swift new file mode 100644 index 0000000..84d1717 --- /dev/null +++ b/macos/GostX/FileRepository.swift @@ -0,0 +1,208 @@ +// macos/GostX/FileRepository.swift +import Foundation + +struct FileInfo: Identifiable { + var id: String { name } + let name: String + let sizeBytes: Int64 + let lastModified: Date +} + +class FileRepository { + let workDir: URL + + init?() { + guard let container = AppGroupConfig.containerURL else { return nil } + workDir = container.appendingPathComponent("files") + try? ensureDir() + } + + // MARK: - Directory + + func ensureDir() throws { + try FileManager.default.createDirectory(at: workDir, + withIntermediateDirectories: true, attributes: nil) + } + + // MARK: - List + + func listFiles() -> [FileInfo] { + guard let contents = try? FileManager.default.contentsOfDirectory( + at: workDir, + includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey], + options: .skipsHiddenFiles + ) else { return [] } + return contents + .filter { url in + var isDir: ObjCBool = false + FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir) + return !isDir.boolValue + } + .compactMap { url in + guard let attrs = try? url.resourceValues(forKeys: [ + .fileSizeKey, .contentModificationDateKey + ]) else { return nil } + return FileInfo( + name: url.lastPathComponent, + sizeBytes: Int64(attrs.fileSize ?? 0), + lastModified: attrs.contentModificationDate ?? Date.distantPast + ) + } + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + } + + // MARK: - Exists + + func exists(_ name: String) -> Bool { + guard let trimmed = try? validateName(name) else { return false } + let url = workDir.appendingPathComponent(trimmed) + var isDir: ObjCBool = false + return FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir) + && !isDir.boolValue + } + + // MARK: - Import + + @discardableResult + func importFile(from sourceURL: URL) throws -> FileInfo { + let name = sourceURL.lastPathComponent + let trimmed = try validateName(name) + + // Reject directory sources — we only copy regular files. + var isDir: ObjCBool = false + FileManager.default.fileExists(atPath: sourceURL.path, isDirectory: &isDir) + guard !isDir.boolValue else { + throw FileRepositoryError.notAFile(name) + } + + // Security-scoped bookmark: .fileImporter provides URLs that require + // explicit access in sandboxed apps. + let accessed = sourceURL.startAccessingSecurityScopedResource() + defer { if accessed { sourceURL.stopAccessingSecurityScopedResource() } } + + // Ensure the files/ directory exists before copying. + try ensureDir() + + let target = workDir.appendingPathComponent(trimmed) + // Remove existing file before copy + if FileManager.default.fileExists(atPath: target.path) { + try FileManager.default.removeItem(at: target) + } + try FileManager.default.copyItem(at: sourceURL, to: target) + let attrs = try target.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]) + return FileInfo( + name: trimmed, + sizeBytes: Int64(attrs.fileSize ?? 0), + lastModified: attrs.contentModificationDate ?? Date() + ) + } + + // MARK: - Export + + func exportFile(_ name: String, to destURL: URL) throws { + let trimmed = try validateName(name) + let source = workDir.appendingPathComponent(trimmed) + guard FileManager.default.fileExists(atPath: source.path) else { + throw FileRepositoryError.fileNotFound(trimmed) + } + if FileManager.default.fileExists(atPath: destURL.path) { + try FileManager.default.removeItem(at: destURL) + } + try FileManager.default.copyItem(at: source, to: destURL) + } + + // MARK: - Rename + + func renameFile(_ oldName: String, to newName: String) throws { + let trimmedOld = try validateName(oldName) + let trimmedNew = try validateName(newName) + let oldURL = workDir.appendingPathComponent(trimmedOld) + let newURL = workDir.appendingPathComponent(trimmedNew) + guard FileManager.default.fileExists(atPath: oldURL.path) else { + throw FileRepositoryError.fileNotFound(trimmedOld) + } + if FileManager.default.fileExists(atPath: newURL.path) { + throw FileRepositoryError.fileAlreadyExists(trimmedNew) + } + try FileManager.default.moveItem(at: oldURL, to: newURL) + } + + // MARK: - Delete + + func deleteFile(_ name: String) throws { + let trimmed = try validateName(name) + let url = workDir.appendingPathComponent(trimmed) + if !FileManager.default.fileExists(atPath: url.path) { return } + var isDir: ObjCBool = false + FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir) + guard !isDir.boolValue else { + throw FileRepositoryError.notAFile(trimmed) + } + try FileManager.default.removeItem(at: url) + } + + // MARK: - Read / Write + + func createFile(_ name: String) throws { + let trimmed = try validateName(name) + let url = workDir.appendingPathComponent(trimmed) + if FileManager.default.fileExists(atPath: url.path) { + throw FileRepositoryError.fileAlreadyExists(trimmed) + } + try "".write(to: url, atomically: true, encoding: .utf8) + } + + func readFileContent(_ name: String) -> String? { + guard let trimmed = try? validateName(name) else { return nil } + let url = workDir.appendingPathComponent(trimmed) + return try? String(contentsOf: url, encoding: .utf8) + } + + func writeFileContent(_ name: String, content: String) throws { + let trimmed = try validateName(name) + let url = workDir.appendingPathComponent(trimmed) + try content.write(to: url, atomically: true, encoding: .utf8) + } + + // MARK: - Path + + func filePath(_ name: String) -> String { + let safe = (try? validateName(name)) ?? name + return workDir.appendingPathComponent(safe).path + } + + // MARK: - Validation + + private func validateName(_ name: String) throws -> String { + let trimmed = name.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || trimmed.contains("..") || trimmed.contains("/") { + throw FileRepositoryError.invalidName + } + return trimmed + } +} + +// MARK: - Errors + +enum FileRepositoryError: LocalizedError { + case invalidName + case fileNotFound(String) + case fileAlreadyExists(String) + case notAFile(String) + + var errorDescription: String? { + switch self { + case .invalidName: + return NSLocalizedString("Invalid filename.", comment: "") + case .fileNotFound(let name): + return String.localizedStringWithFormat( + NSLocalizedString("File \"%@\" not found.", comment: ""), name) + case .fileAlreadyExists(let name): + return String.localizedStringWithFormat( + NSLocalizedString("File \"%@\" already exists.", comment: ""), name) + case .notAFile(let name): + return String.localizedStringWithFormat( + NSLocalizedString("\"%@\" is not a regular file.", comment: ""), name) + } + } +} diff --git a/macos/GostX/GostX.entitlements b/macos/GostX/GostX.entitlements index 7a2230d..1a0a085 100644 --- a/macos/GostX/GostX.entitlements +++ b/macos/GostX/GostX.entitlements @@ -4,9 +4,19 @@ com.apple.security.app-sandbox + com.apple.security.application-groups + + group.cn.liukebin.gostx + + com.apple.security.files.user-selected.read-write + com.apple.security.network.client com.apple.security.network.server + com.apple.developer.networking.networkextension + + packet-tunnel-provider + diff --git a/macos/GostX/LogContentView.swift b/macos/GostX/LogContentView.swift new file mode 100644 index 0000000..6400f17 --- /dev/null +++ b/macos/GostX/LogContentView.swift @@ -0,0 +1,106 @@ +// macos/GostX/LogContentView.swift +import SwiftUI + +@available(macOS 14.0, *) +struct LogContentView: View { + @ObservedObject var vm: LogViewModel + let loggingEnabled: Bool + + var body: some View { + VStack(spacing: 0) { + // Content + if !loggingEnabled { + VStack { + Spacer() + Image(systemName: "text.alignleft") + .font(.system(size: 28)) + .foregroundColor(Color(nsColor: .tertiaryLabelColor)) + Text(NSLocalizedString("Logging is off", comment: "")) + .font(.system(size: 13)) + .foregroundColor(.secondary) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if vm.lines.isEmpty { + VStack { + Spacer() + Image(systemName: "text.alignleft") + .font(.system(size: 28)) + .foregroundColor(Color(nsColor: .tertiaryLabelColor)) + Text(NSLocalizedString("No logs", comment: "")) + .font(.system(size: 13)) + .foregroundColor(.secondary) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 1) { + ForEach(Array(vm.lines.enumerated()), id: \.offset) { _, line in + Text(line) + .font(.system(size: 11, design: .monospaced)) + .textSelection(.enabled) + } + } + .padding(8) + } + .background(Color(nsColor: .textBackgroundColor)) + .onAppear { vm.scrollProxy = proxy } + } + } + + // Bottom toolbar (only when logging is on) + if loggingEnabled { + Divider() + + HStack(spacing: 8) { + Button(action: { vm.isFollowing.toggle() }) { + Image(systemName: vm.isFollowing ? "pause.fill" : "play.fill") + } + .buttonStyle(.borderless) + .help(vm.isFollowing + ? NSLocalizedString("Pause auto-scroll", comment: "") + : NSLocalizedString("Resume auto-scroll", comment: "")) + + Divider() + .frame(height: 16) + + Button(action: { vm.copyAll() }) { + Image(systemName: "doc.on.doc") + } + .buttonStyle(.borderless) + .help(NSLocalizedString("Copy all", comment: "")) + + Button(action: { vm.clearLog() }) { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .help(NSLocalizedString("Clear log", comment: "")) + + Spacer() + + Text("\(vm.lines.count) lines") + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(height: 32) + } + } + .ignoresSafeArea(.container, edges: .top) + .onAppear { vm.onAppear(loggingEnabled: loggingEnabled) } + .onDisappear { vm.onDisappear() } + .onChange(of: vm.isFollowing) { _ in + if vm.isFollowing, let proxy = vm.scrollProxy, !vm.lines.isEmpty { + proxy.scrollTo(vm.lines.count - 1, anchor: .bottom) + } + } + .onChange(of: vm.lines.count) { _ in + if vm.isFollowing, let proxy = vm.scrollProxy, !vm.lines.isEmpty { + proxy.scrollTo(vm.lines.count - 1, anchor: .bottom) + } + } + } +} diff --git a/macos/GostX/LogOptionsView.swift b/macos/GostX/LogOptionsView.swift new file mode 100644 index 0000000..475ae84 --- /dev/null +++ b/macos/GostX/LogOptionsView.swift @@ -0,0 +1,76 @@ +// macos/GostX/LogOptionsView.swift +import SwiftUI + +@available(macOS 14.0, *) +struct LogOptionsView: View { + @Binding var loggingEnabled: Bool + @Binding var logLevel: String + + var body: some View { + VStack(alignment: .leading, spacing: 16) { +// Text(NSLocalizedString("Logging", comment: "")) +// .font(.system(size: 13, weight: .semibold)) +// .foregroundColor(.primary) +// .padding(.horizontal, 12) + + VStack(spacing: 0) { + // Enable Logging toggle row + HStack { + Text(NSLocalizedString("Enable Logging", comment: "")) + .font(.system(size: 13)) + Spacer() + Toggle("", isOn: $loggingEnabled) + .toggleStyle(.switch) + .controlSize(.small) + .labelsHidden() + .onChange(of: loggingEnabled) { newValue in + AppGroupConfig.loggingEnabled = newValue + AppLogger.log(.info, "Logging \(newValue ? "enabled" : "disabled")") + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + + Divider() + .padding(.leading, 12) + + // Log Level picker row + HStack { + Text(NSLocalizedString("Log Level", comment: "")) + .font(.system(size: 13)) + Spacer() + Picker("", selection: $logLevel) { + ForEach(AppGroupConfig.logLevelOptions, id: \.self) { level in + Text(level.capitalized).tag(level) + } + } + .pickerStyle(.menu) + .labelsHidden() + .font(.system(size: 13)) + .onChange(of: logLevel) { newValue in + AppGroupConfig.logLevel = newValue + AppLogger.log(.info, "Log level: \(newValue)") + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(nsColor: .quaternaryLabelColor).opacity(0.3)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + + Text(NSLocalizedString("Restart VPN to apply", comment: "")) + .font(.system(size: 11)) + .foregroundColor(.secondary) + .padding(.horizontal, 12) + + Spacer() + } + .padding(16) + } +} diff --git a/macos/GostX/LogViewModel.swift b/macos/GostX/LogViewModel.swift new file mode 100644 index 0000000..5dfdce9 --- /dev/null +++ b/macos/GostX/LogViewModel.swift @@ -0,0 +1,67 @@ +// macos/GostX/LogViewModel.swift +import SwiftUI +import Combine + +@MainActor +class LogViewModel: ObservableObject { + @Published var lines: [String] = [] + @Published var isFollowing = false + + var scrollProxy: ScrollViewProxy? + private var timer: Timer? + private let logFileURL: URL? + + init(logFileURL: URL? = nil) { + self.logFileURL = logFileURL ?? AppGroupConfig.containerURL?.appendingPathComponent("gost.log") + } + + func onAppear(loggingEnabled: Bool) { + loadLog() + if loggingEnabled { + startPolling() + } + } + + func onDisappear() { + stopPolling() + } + + func copyAll() { + let text = lines.joined(separator: "\n") + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + + func clearLog() { + guard let url = logFileURL else { return } + try? "".write(to: url, atomically: true, encoding: .utf8) + lines = [] + } + + // MARK: - Private + + private func loadLog() { + guard let url = logFileURL, + FileManager.default.fileExists(atPath: url.path), + let content = try? String(contentsOf: url, encoding: .utf8) + else { + lines = [] + return + } + lines = content.components(separatedBy: "\n").filter { !$0.isEmpty } + } + + private func startPolling() { + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + guard let self else { return } + DispatchQueue.main.async { + self.loadLog() + } + } + } + + private func stopPolling() { + timer?.invalidate() + timer = nil + } +} diff --git a/macos/GostX/MacExtrasConfigurator.swift b/macos/GostX/MacExtrasConfigurator.swift index e97b44e..b7f2f77 100644 --- a/macos/GostX/MacExtrasConfigurator.swift +++ b/macos/GostX/MacExtrasConfigurator.swift @@ -8,6 +8,7 @@ import Foundation import AppKit import SwiftUI +import NetworkExtension class MacExtrasConfigurator: NSObject, NSMenuDelegate { private var delegate: AppDelegate @@ -16,49 +17,60 @@ class MacExtrasConfigurator: NSObject, NSMenuDelegate { public var statusActionOnItem: NSMenuItem public var statusActionOffItem: NSMenuItem public var statusActionRestartItem: NSMenuItem - public var statusListenItem: NSMenuItem - public var argsItem: NSMenuItem = NSMenuItem() - + public var profileMenuItem: NSMenuItem = NSMenuItem() + private var menu: NSMenu = NSMenu() - private var activeImg: NSImage? = NSImage(named: "StatusBarActive") - private var inActiveImg: NSImage? = NSImage(named: "StatusBarInactive") - private var settingsHostingController: NSHostingController? - + private var activeImg: NSImage? + private var inActiveImg: NSImage? + private var settingsHostingController: NSHostingController? + + private var settingsWindow: NSWindow? + init(delegate: AppDelegate) { self.delegate = delegate - + statusBar = NSStatusBar.system statusBarItem = statusBar.statusItem(withLength: NSStatusItem.squareLength) - + + activeImg = NSImage(named: "StatusBarActive") + activeImg?.isTemplate = true + inActiveImg = NSImage(named: "StatusBarInactive") + inActiveImg?.isTemplate = true + statusBarItem.button?.image = inActiveImg + statusActionOnItem = NSMenuItem() statusActionOffItem = NSMenuItem() statusActionRestartItem = NSMenuItem() - statusListenItem = NSMenuItem() - + super.init() - + + // Observe VPN status changes from system (covers toggling from System Settings) + NotificationCenter.default.addObserver( + forName: .NEVPNStatusDidChange, + object: nil, + queue: .main + ) { [weak self] notification in + guard let connection = notification.object as? NEVPNConnection else { return } + switch connection.status { + case .connected: + self?.toOnState() + case .disconnected, .invalid: + self?.toOffState() + default: + break + } + } + createMenu() + toOffState() } - + // MARK: - MenuConfig - + private func createMenu() { menu.delegate = self menu.autoenablesItems = false - - // Listening - statusListenItem.isEnabled = false - menu.addItem(statusListenItem) - - menu.addItem(.separator()) - - // Configuration - argsItem.target = self - argsItem.action = #selector(onConfigClick) - menu.addItem(argsItem) - - menu.addItem(.separator()) - + // Actions statusActionOnItem.title = NSLocalizedString("Start", comment: "start the service") statusActionOnItem.image = NSImage(systemSymbolName: "play.fill", accessibilityDescription: "") @@ -66,34 +78,39 @@ class MacExtrasConfigurator: NSObject, NSMenuDelegate { statusActionOnItem.isEnabled = false statusActionOnItem.action = #selector(onStartClick) menu.addItem(statusActionOnItem) - + statusActionOffItem.title = NSLocalizedString("Stop", comment: "stop the service") statusActionOffItem.image = NSImage(systemSymbolName: "stop.fill", accessibilityDescription: "") statusActionOffItem.target = self statusActionOffItem.isEnabled = false statusActionOffItem.action = #selector(onStopClick) menu.addItem(statusActionOffItem) - + statusActionRestartItem.title = NSLocalizedString("Restart", comment: "") statusActionRestartItem.image = NSImage(systemSymbolName: "gobackward", accessibilityDescription: "") statusActionRestartItem.target = self statusActionRestartItem.isEnabled = false statusActionRestartItem.action = #selector(onRestartClick) menu.addItem(statusActionRestartItem) - + menu.addItem(.separator()) - - let configMenuItem = NSMenuItem() - configMenuItem.title = NSLocalizedString("Settings...", comment: "") - configMenuItem.image = NSImage(systemSymbolName: "gear", accessibilityDescription: "") - configMenuItem.keyEquivalent = "," - configMenuItem.keyEquivalentModifierMask = .command - configMenuItem.target = self - configMenuItem.action = #selector(onConfigClick) - menu.addItem(configMenuItem) - + + // Configuration profiles submenu + profileMenuItem.title = NSLocalizedString("Profile", comment: "") + profileMenuItem.image = NSImage(systemSymbolName: "doc.text", accessibilityDescription: "") + menu.addItem(profileMenuItem) + + let settingsItem = NSMenuItem() + settingsItem.title = NSLocalizedString("Settings...", comment: "") + settingsItem.image = NSImage(systemSymbolName: "gear", accessibilityDescription: "") + settingsItem.keyEquivalent = "," + settingsItem.keyEquivalentModifierMask = .command + settingsItem.target = self + settingsItem.action = #selector(onSettingsClick) + menu.addItem(settingsItem) + menu.addItem(.separator()) - + let quitItem = NSMenuItem() quitItem.title = NSLocalizedString("Quit", comment: "") quitItem.image = NSImage(systemSymbolName: "power", accessibilityDescription: "") @@ -102,76 +119,128 @@ class MacExtrasConfigurator: NSObject, NSMenuDelegate { quitItem.keyEquivalentModifierMask = .command quitItem.action = #selector(onQuitClick) menu.addItem(quitItem) - + statusBarItem.menu = menu } - - // MARK: - Actions - - @objc private func onArgumentClick(_ sender: Any?) { - onConfigClick(sender) + + // MARK: - Profile Submenu + + private func rebuildProfileSubmenu() { + let repo = ConfigRepository.shared + let submenu = NSMenu() + submenu.autoenablesItems = false + + for profile in repo.profiles { + let item = NSMenuItem() + item.title = profile.name + item.state = profile.id == repo.activeProfileId ? .on : .off + item.target = self + item.action = #selector(onSelectProfile(_:)) + item.representedObject = profile.id + submenu.addItem(item) + } + + if repo.profiles.isEmpty { + let emptyItem = NSMenuItem() + emptyItem.title = NSLocalizedString("No profiles", comment: "") + emptyItem.isEnabled = false + submenu.addItem(emptyItem) + } + + submenu.addItem(.separator()) + + let manageItem = NSMenuItem() + manageItem.title = NSLocalizedString("Manage Profiles...", comment: "") + manageItem.target = self + manageItem.action = #selector(onSettingsClick) + submenu.addItem(manageItem) + + profileMenuItem.submenu = submenu } - - @objc private func onConfigClick(_ sender: Any?) { - NSApp.activate(ignoringOtherApps: true) - NSApp.setActivationPolicy(.regular) - settingsHostingController = NSHostingController(rootView: SettingsView()) + @objc private func onSelectProfile(_ sender: NSMenuItem) { + guard let profileId = sender.representedObject as? String else { return } + ConfigRepository.shared.setActiveProfile(profileId) + } + + // MARK: - Actions + + @objc private func onSettingsClick(_ sender: Any?) { + guard #available(macOS 14.0, *) else { return } + if let window = settingsWindow, window.isVisible { + window.orderFrontRegardless() + window.makeKey() + return + } + settingsHostingController = NSHostingController(rootView: AnyView(SettingsView())) + let toolbar = NSToolbar(identifier: "SettingsToolbar") + toolbar.displayMode = .iconOnly + toolbar.delegate = ToolbarDelegate.shared + let window = NSWindow(contentViewController: settingsHostingController!) - window.title = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Settings Window" - window.setContentSize(NSSize(width: 800, height: 400)) - window.makeKeyAndOrderFront(nil) + window.styleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView] + window.titlebarAppearsTransparent = true + window.toolbarStyle = .unified + window.toolbar = toolbar + window.title = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "GostX" + window.setContentSize(NSSize(width: 780, height: 500)) + window.center() window.orderFrontRegardless() + window.makeKey() + window.isReleasedWhenClosed = false + settingsWindow = window } - + @objc private func onQuitClick(_ sender: Any?) { delegate.quit() } - + @objc private func onStopClick(_ sender: Any?) { delegate.stop() } - + @objc private func onStartClick(_ sender: Any?) { delegate.start() } - + @objc private func onRestartClick(_ sender: Any?) { delegate.stop() delegate.start() } - - public func updateListen(_ listen: String?) { - if listen == nil { - statusListenItem.isHidden = true - return - } - statusListenItem.isHidden = false - statusListenItem.attributedTitle = NSAttributedString(string: "\(listen!.replacingOccurrences(of: ";", with: "\n"))") - } - + public func toOffState() { self.statusActionOnItem.isEnabled = true self.statusActionOffItem.isEnabled = false self.statusActionRestartItem.isEnabled = false self.statusBarItem.button?.image = inActiveImg } - + public func toOnState() { self.statusActionOnItem.isEnabled = false self.statusActionOffItem.isEnabled = true self.statusActionRestartItem.isEnabled = true self.statusBarItem.button?.image = activeImg } - + func menuWillOpen(_ menu: NSMenu) { - updateArgsMenuItem() + rebuildProfileSubmenu() } - - private func updateArgsMenuItem() { - argsItem.title = NSLocalizedString("Configuration", comment: "") - argsItem.image = NSImage(systemSymbolName: "doc.text", accessibilityDescription: "") - argsItem.toolTip = UserDefaults.standard.string(forKey: defaultsArgumentsKey) ?? defaultGostYAML - argsItem.submenu = nil +} + +@available(macOS 14.0, *) +final class ToolbarDelegate: NSObject, NSToolbarDelegate { + static let shared = ToolbarDelegate() + + func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { + let item = NSToolbarItem(itemIdentifier: itemIdentifier) + return item + } + + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + [.flexibleSpace] + } + + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + [.flexibleSpace] } } diff --git a/macos/GostX/ProfileListView.swift b/macos/GostX/ProfileListView.swift new file mode 100644 index 0000000..4b3eba1 --- /dev/null +++ b/macos/GostX/ProfileListView.swift @@ -0,0 +1,105 @@ +// macos/GostX/ProfileListView.swift +import SwiftUI + +@available(macOS 14.0, *) +struct ProfileListView: View { + @ObservedObject var repo = ConfigRepository.shared + @Binding var selectedProfileId: String? + @State private var showAddSheet = false + @State private var showRenameSheet = false + @State private var newProfileName = "" + @State private var renameTargetId: String? = nil + + var body: some View { + VStack(spacing: 0) { + List(selection: $selectedProfileId) { + ForEach(repo.profiles) { profile in + Label(profile.name, systemImage: "doc.text") + .tag(profile.id) + .contextMenu { + Button(NSLocalizedString("Rename...", comment: "")) { + renameTargetId = profile.id + newProfileName = profile.name + showRenameSheet = true + } + Divider() + Button(NSLocalizedString("Delete...", comment: ""), role: .destructive) { + repo.deleteProfile(profile.id) + } + } + } + } + .listStyle(.automatic) + .padding(.horizontal, 4) + + Divider() + + HStack { + Button(action: { showAddSheet = true }) { + Label(NSLocalizedString("Add Profile", comment: ""), systemImage: "plus") + } + .buttonStyle(.borderless) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(height: 32) + } + .sheet(isPresented: $showAddSheet) { + addProfileSheet + } + .sheet(isPresented: $showRenameSheet) { + renameProfileSheet + } + } + + private var addProfileSheet: some View { + VStack(spacing: 16) { + Text(NSLocalizedString("New Profile", comment: "")).font(.headline) + TextField(NSLocalizedString("Profile name", comment: ""), text: $newProfileName) + .textFieldStyle(.roundedBorder) + .frame(width: 250) + HStack { + Button(NSLocalizedString("Cancel", comment: "")) { showAddSheet = false } + .keyboardShortcut(.cancelAction) + Button(NSLocalizedString("Add", comment: "")) { + if !newProfileName.isEmpty { + let newId = repo.addProfile(name: newProfileName) + if let id = newId { selectedProfileId = id } + newProfileName = "" + showAddSheet = false + } + } + .keyboardShortcut(.defaultAction) + .disabled(newProfileName.isEmpty) + } + } + .padding() + .frame(width: 300, height: 140) + } + + private var renameProfileSheet: some View { + VStack(spacing: 16) { + Text(NSLocalizedString("Rename Profile", comment: "")).font(.headline) + TextField(NSLocalizedString("Profile name", comment: ""), text: $newProfileName) + .textFieldStyle(.roundedBorder) + .frame(width: 250) + HStack { + Button(NSLocalizedString("Cancel", comment: "")) { showRenameSheet = false } + .keyboardShortcut(.cancelAction) + Button(NSLocalizedString("Rename", comment: "")) { + if let id = renameTargetId, !newProfileName.isEmpty { + _ = repo.renameProfile(id, newName: newProfileName) + newProfileName = "" + renameTargetId = nil + showRenameSheet = false + } + } + .keyboardShortcut(.defaultAction) + .disabled(newProfileName.isEmpty) + } + } + .padding() + .frame(width: 300, height: 140) + } +} diff --git a/macos/GostX/SettingsView.swift b/macos/GostX/SettingsView.swift index 971e893..39c2c09 100644 --- a/macos/GostX/SettingsView.swift +++ b/macos/GostX/SettingsView.swift @@ -1,60 +1,173 @@ // macos/GostX/SettingsView.swift import SwiftUI -import HighlightedTextEditor -let reOpts = NSRegularExpression.Options([.anchorsMatchLines]) -let yamlKeyRule = try! NSRegularExpression( - pattern: "^(\\s*)(services|chains|hops|name|addr|handler|listener|connector|dialer|type|chain|auth|tls|metadata|bypass|resolver|hosts|retries|timeout)\\s*:", - options: reOpts) -let yamlCommentRule = try! NSRegularExpression(pattern: "^\\s*#.*", options: reOpts) +// MARK: - Category +enum SettingsCategory: String, CaseIterable, Identifiable { + case profiles + case files + case logs + + var id: Self { self } + + var icon: String { + switch self { + case .profiles: return "doc.text" + case .files: return "folder" + case .logs: return "text.alignleft" + } + } + + var label: String { + switch self { + case .profiles: return NSLocalizedString("Profiles", comment: "") + case .files: return NSLocalizedString("Files", comment: "") + case .logs: return NSLocalizedString("Logs", comment: "") + } + } +} + +// MARK: - SettingsView + +@available(macOS 14.0, *) struct SettingsView: View { + @State private var selectedCategory: SettingsCategory = .profiles + + // Profiles + @State private var selectedProfileId: String? = nil + + // Files + @StateObject private var fileVM = FileManageViewModel() + + // Logs + @StateObject private var logVM = LogViewModel() + @State private var loggingEnabled = AppGroupConfig.loggingEnabled + @State private var logLevel = AppGroupConfig.logLevel + var body: some View { - TabView { - YamlConfigView() - .tabItem { - Label(NSLocalizedString("Configuration", comment: ""), systemImage: "doc.text") + NavigationSplitView { + // Sidebar + List(selection: $selectedCategory) { + ForEach(SettingsCategory.allCases) { category in + Label(category.label, systemImage: category.icon) + .tag(category) + } + } + .listStyle(.sidebar) + .navigationSplitViewColumnWidth(min: 140, ideal: 160, max: 200) + } content: { + // Content + switch selectedCategory { + case .profiles: + ProfileListView(selectedProfileId: $selectedProfileId) + .navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 300) + case .files: + FileListView(vm: fileVM) + .navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 300) + case .logs: + LogOptionsView(loggingEnabled: $loggingEnabled, logLevel: $logLevel) + .navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 300) + } + } detail: { + // Detail + switch selectedCategory { + case .profiles: + if let profileId = selectedProfileId { + YamlEditorView(profileId: profileId) + .id(profileId) + .ignoresSafeArea(.container, edges: .top) + } else { + placeholderView( + icon: "doc.text.magnifyingglass", + text: NSLocalizedString("Select a profile to edit", comment: "") + ) } - .tag("config") + case .files: + if fileVM.selectedFileName != nil { + FileContentView(vm: fileVM) + } else { + placeholderView( + icon: "doc.text.magnifyingglass", + text: NSLocalizedString("Select a file to view", comment: "") + ) + } + case .logs: + LogContentView(vm: logVM, loggingEnabled: loggingEnabled) + .onAppear { + loggingEnabled = AppGroupConfig.loggingEnabled + } + } } - .padding(5) + .ignoresSafeArea(.container, edges: .top) + .frame(minWidth: 700, minHeight: 440) + } + + private func placeholderView(icon: String, text: String) -> some View { + VStack { + Image(systemName: icon) + .font(.system(size: 28)) + .foregroundColor(Color(nsColor: .tertiaryLabelColor)) + Text(text) + .font(.system(size: 13)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } } -struct YamlConfigView: View { - @AppStorage(defaultsArgumentsKey) - private var yamlConfig = defaultGostYAML - - private let rules: [HighlightRule] = [ - HighlightRule( - pattern: yamlCommentRule, - formattingRule: TextFormattingRule(key: .foregroundColor, value: NSColor.systemGray) - ), - HighlightRule( - pattern: yamlKeyRule, - formattingRules: [ - TextFormattingRule(fontTraits: .bold), - TextFormattingRule(key: .foregroundColor, value: NSColor.systemBlue), - ] - ), - ] +// MARK: - YAML Editor View + +@available(macOS 14.0, *) +struct YamlEditorView: View { + let profileId: String + @StateObject private var repo = ConfigRepository.shared + @State private var yamlText: String = "" + @State private var isDirty = false + @State private var originalText: String = "" var body: some View { - VStack { - HighlightedTextEditor(text: $yamlConfig, highlightRules: rules) - .introspect { editor in - editor.textView.allowsUndo = true - editor.textView.breakUndoCoalescing() - editor.textView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) + VStack(spacing: 0) { + YamlTextView(text: $yamlText) + .onChange(of: yamlText) { newValue in + isDirty = newValue != originalText } - Text("gost v3 YAML configuration — https://gost.run/docs/") - .padding(.horizontal, 5) - .font(Font.system(size: 12)) - .foregroundColor(.gray) + .onAppear { + let text = repo.getConfig(profileId) + yamlText = text + originalText = text + isDirty = false + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Divider() + + HStack { + Spacer() + Button(action: { + save() + originalText = yamlText + isDirty = false + }) { + Label(NSLocalizedString("Save", comment: ""), systemImage: "square.and.arrow.down") + } + .buttonStyle(.borderless) + .disabled(!isDirty) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(height: 32) + } + } + + private func save() { + repo.saveConfig(profileId, yaml: yamlText) + if profileId == repo.activeProfileId { + AppGroupConfig.writeYaml(yamlText) } } } +@available(macOS 14.0, *) struct SettingsView_Previews: PreviewProvider { static var previews: some View { SettingsView() diff --git a/macos/GostX/VpnManager.swift b/macos/GostX/VpnManager.swift new file mode 100644 index 0000000..8d95a20 --- /dev/null +++ b/macos/GostX/VpnManager.swift @@ -0,0 +1,82 @@ +// macos/GostX/VpnManager.swift +import Foundation +import NetworkExtension +import os + +/// 管理 NETunnelProviderManager 生命周期:加载/保存 VPN 配置、启停连接。 +@MainActor +class VpnManager: ObservableObject { + static let shared = VpnManager() + + @Published var status: NEVPNStatus = .invalid + + private let tunnelBundleId = "cn.liukebin.gostx.tunnel" + private var manager: NETunnelProviderManager? + private var statusObserver: NSObjectProtocol? + + private init() {} + + // MARK: - Setup + + /// 首次调用时加载已存在的 VPN 配置,或创建新的。 + /// 必须在应用启动时调用一次。 + func setup() async { + // 已有 manager 则跳过 + if manager != nil { return } + + let managers = (try? await NETunnelProviderManager.loadAllFromPreferences()) ?? [] + if let existing = managers.first(where: { + ($0.protocolConfiguration as? NETunnelProviderProtocol)? + .providerBundleIdentifier == tunnelBundleId + }) { + manager = existing + status = existing.connection.status + } else { + let m = NETunnelProviderManager() + let proto = NETunnelProviderProtocol() + proto.providerBundleIdentifier = tunnelBundleId + proto.serverAddress = "GostX" + m.protocolConfiguration = proto + m.isEnabled = true + m.localizedDescription = "GostX VPN" + try? await m.saveToPreferences() + manager = m + } + observeStatus() + } + + // MARK: - Control + + func start() async throws { + guard let m = manager else { + os_log(.error, "[GostX] VpnManager.start: manager is nil") + return + } + os_log(.default, "[GostX] VpnManager.start: calling startVPNTunnel, status=%d", m.connection.status.rawValue) + try m.connection.startVPNTunnel() + os_log(.default, "[GostX] VpnManager.start: startVPNTunnel returned, status=%d", m.connection.status.rawValue) + } + + func stop() { + manager?.connection.stopVPNTunnel() + } + + // MARK: - Status observation + + private func observeStatus() { + statusObserver = NotificationCenter.default.addObserver( + forName: .NEVPNStatusDidChange, + object: manager?.connection, + queue: .main + ) { [weak self] _ in + guard let self, let m = self.manager else { return } + self.status = m.connection.status + } + } + + deinit { + if let observer = statusObserver { + NotificationCenter.default.removeObserver(observer) + } + } +} diff --git a/macos/GostX/YamlEditorView.swift b/macos/GostX/YamlEditorView.swift new file mode 100644 index 0000000..0376e1e --- /dev/null +++ b/macos/GostX/YamlEditorView.swift @@ -0,0 +1,136 @@ +// +// YamlEditorView.swift +// GostX +// +// SwiftUI wrapper around NSTextView with real-time YAML syntax highlighting. +// Supports undo naturally because it never replaces the entire NSTextStorage. +// + +import SwiftUI +import AppKit + +/// A YAML text editor with syntax highlighting and native undo support. +struct YamlTextView: NSViewRepresentable { + @Binding var text: String + + // MARK: - YAML Syntax Highlighting + + private static let rules: [(NSRegularExpression, [NSAttributedString.Key: Any])] = { + let commentFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) + let keyFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .bold) + let valueFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) + + let commentColor = NSColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1.0) + let keyColor = NSColor(red: 0.2, green: 0.4, blue: 0.8, alpha: 1.0) + let stringColor = NSColor(red: 0.1, green: 0.6, blue: 0.1, alpha: 1.0) + let numberColor = NSColor(red: 0.9, green: 0.5, blue: 0.1, alpha: 1.0) + let boolColor = NSColor(red: 0.6, green: 0.2, blue: 0.8, alpha: 1.0) + + let patterns: [(String, [NSAttributedString.Key: Any])] = [ + // comment — must be first to avoid matching inside comments + ("#.*", [.foregroundColor: commentColor, .font: commentFont]), + // string (double/single-quoted) + ("\"[^\"]*\"|'[^']*'", [.foregroundColor: stringColor, .font: valueFont]), + // number (integer / float) + ("(? Coordinator { + Coordinator(self) + } + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSTextView.scrollableTextView() + let textView = scrollView.documentView as! NSTextView + + textView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) + textView.isAutomaticTextReplacementEnabled = false + textView.allowsUndo = true + textView.isEditable = true + textView.isRichText = false + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.containerSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + textView.textContainerInset = NSSize(width: 4, height: 4) + + // Use textStorage delegate for highlighting — doesn't interfere with NSTextViewDelegate + textView.textStorage?.delegate = context.coordinator + + // Initial content + textView.string = text + YamlTextView.applyHighlighting(text, to: textView.textStorage!) + + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + let textView = scrollView.documentView as! NSTextView + let coordinator = context.coordinator + + // Only update when binding changed externally (e.g., load profile) + guard textView.string != text else { return } + coordinator.needsBindingSync = false // suppress didProcessEditing binding sync + coordinator.updating = true + textView.string = text + YamlTextView.applyHighlighting(text, to: textView.textStorage!) + coordinator.updating = false + coordinator.needsBindingSync = true // enable user-editing binding sync + } +} diff --git a/macos/GostXTests/GostXTests.swift b/macos/GostXTests/GostXTests.swift index ff65854..8c33973 100644 --- a/macos/GostXTests/GostXTests.swift +++ b/macos/GostXTests/GostXTests.swift @@ -34,3 +34,58 @@ class GostXTests: XCTestCase { } } + +@MainActor +class LogViewModelTests: XCTestCase { + var vm: LogViewModel! + var tempDir: URL! + var tempLogURL: URL! + + override func setUpWithError() throws { + tempDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + tempLogURL = tempDir.appendingPathComponent("gost.log") + try "line one\nline two\nline three\n".write(to: tempLogURL, atomically: true, encoding: .utf8) + vm = LogViewModel(logFileURL: tempLogURL) + vm.onAppear(loggingEnabled: true) + } + + override func tearDownWithError() throws { + vm.onDisappear() + try? FileManager.default.removeItem(at: tempDir) + vm = nil + } + + func testLoadsLinesFromFile() { + XCTAssertEqual(vm.lines.count, 3) + XCTAssertEqual(vm.lines[0], "line one") + XCTAssertEqual(vm.lines[1], "line two") + XCTAssertEqual(vm.lines[2], "line three") + } + + func testInitialState() { + XCTAssertFalse(vm.isFollowing) + } + + func testClearLogTruncatesFile() { + vm.clearLog() + XCTAssertEqual(vm.lines.count, 0) + // Verify the temp file was truncated, not the real one + let content = try? String(contentsOf: tempLogURL, encoding: .utf8) + XCTAssertEqual(content, "") + } + + func testCopyAll() { + vm.copyAll() + let pb = NSPasteboard.general.string(forType: .string) + XCTAssertTrue(pb?.contains("line one") ?? false) + XCTAssertTrue(pb?.contains("line three") ?? false) + } + + func testIsFollowingToggle() { + vm.isFollowing = false + XCTAssertFalse(vm.isFollowing) + vm.isFollowing = true + XCTAssertTrue(vm.isFollowing) + } +} diff --git a/macos/GostXTunnel/GostXTunnel.entitlements b/macos/GostXTunnel/GostXTunnel.entitlements new file mode 100644 index 0000000..9d122bc --- /dev/null +++ b/macos/GostXTunnel/GostXTunnel.entitlements @@ -0,0 +1,20 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.cn.liukebin.gostx + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.developer.networking.networkextension + + packet-tunnel-provider + + + diff --git a/macos/GostXTunnel/Info.plist b/macos/GostXTunnel/Info.plist new file mode 100644 index 0000000..883d266 --- /dev/null +++ b/macos/GostXTunnel/Info.plist @@ -0,0 +1,25 @@ + + + + + CFBundleDisplayName + GostX Tunnel + CFBundleName + $(PRODUCT_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + NSExtension + + NSExtensionPointIdentifier + com.apple.networkextension.packet-tunnel + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).PacketTunnelProvider + + + diff --git a/macos/GostXTunnel/PacketTunnelProvider.swift b/macos/GostXTunnel/PacketTunnelProvider.swift new file mode 100644 index 0000000..927216c --- /dev/null +++ b/macos/GostXTunnel/PacketTunnelProvider.swift @@ -0,0 +1,120 @@ +// macos/GostXTunnel/PacketTunnelProvider.swift +import NetworkExtension +import Libgost +import os + +class PacketTunnelProvider: NEPacketTunnelProvider { + + override func startTunnel(options: [String: NSObject]?) async throws { + os_log(.default, "[GostX] startTunnel called") + + // 1. 设置工作目录和日志 + setWorkDirAndLog() + os_log(.default, "[GostX] workDir and log set, containerURL=%@", AppGroupConfig.containerURL?.path ?? "nil") + + // 2. 从 App Group 读取 YAML 配置 + let yaml = AppGroupConfig.readYaml() + os_log(.default, "[GostX] yaml read, length=%d", yaml?.count ?? 0) + guard let yaml = yaml, !yaml.isEmpty else { + os_log(.error, "[GostX] no YAML config") + cancelTunnelWithError(NSError( + domain: "GostXTunnel", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No YAML config in App Group"] + )) + return + } + + // 3. 启动 gost 服务 + os_log(.default, "[GostX] starting gost...") + var err: NSError? + let ok = LibgostStartGost(yaml, "", &err) + os_log(.default, "[GostX] StartGost returned ok=%d err=%{public}@", ok, err?.localizedDescription ?? "nil") + if !ok, let err { + cancelTunnelWithError(NSError( + domain: "GostXTunnel.startGost", + code: 2, + userInfo: [NSLocalizedDescriptionKey: err.localizedDescription] + )) + return + } + + // 4. 配置 TUN 网络设置 + os_log(.default, "[GostX] setting network settings...") + let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1") + settings.mtu = 1500 + // Both 10.0.0.2 and 10.0.0.3 must be registered so the kernel + // accepts packets the system stack rewrites (source→10.0.0.3). + let ipv4 = NEIPv4Settings(addresses: ["10.0.0.2", "10.0.0.3"], subnetMasks: ["255.255.255.0", "255.255.255.0"]) + ipv4.includedRoutes = [NEIPv4Route.default()] + settings.ipv4Settings = ipv4 + settings.dnsSettings = NEDNSSettings(servers: ["10.0.0.3"]) + + do { + try await setTunnelNetworkSettings(settings) + os_log(.default, "[GostX] network settings applied") + } catch { + os_log(.error, "[GostX] setTunnelNetworkSettings failed: %@", error.localizedDescription) + LibgostStopGost(nil) + cancelTunnelWithError(NSError( + domain: "GostXTunnel.setNetworkSettings", + code: 3, + userInfo: [NSLocalizedDescriptionKey: error.localizedDescription] + )) + return + } + + // 5. 扫描找到 utun fd + let tunFd = LibgostGetTunnelFileDescriptor() + os_log(.default, "[GostX] tunFd=%d", tunFd) + guard tunFd != -1 else { + LibgostStopGost(nil) + cancelTunnelWithError(NSError( + domain: "GostXTunnel", + code: 4, + userInfo: [NSLocalizedDescriptionKey: "Cannot locate TUN file descriptor"] + )) + return + } + + // 6. 启动 TUN stack + os_log(.default, "[GostX] starting TUN stack...") + if !LibgostStartTun(Int(tunFd), 1500, &err), let err { + os_log(.error, "[GostX] StartTun failed: %@", err.localizedDescription) + LibgostStopGost(nil) + cancelTunnelWithError(NSError( + domain: "GostXTunnel.startTun", + code: 5, + userInfo: [NSLocalizedDescriptionKey: err.localizedDescription] + )) + return + } + os_log(.default, "[GostX] tunnel started successfully!") + } + + override func stopTunnel(with reason: NEProviderStopReason) async { + os_log(.default, "[GostX] stopTunnel, reason=%d", reason.rawValue) + LibgostStopTun(nil) + LibgostStopGost(nil) + } + + private func setWorkDirAndLog() { + guard let containerURL = AppGroupConfig.containerURL else { + os_log(.error, "[GostX] containerURL is nil!") + return + } + let filesDir = containerURL.appendingPathComponent("files") + try? FileManager.default.createDirectory(at: filesDir, + withIntermediateDirectories: true, attributes: nil) + LibgostSetWorkDir(filesDir.path, nil) + + // Read logging preferences from shared UserDefaults + let level = AppGroupConfig.loggingEnabled ? AppGroupConfig.logLevel : "off" + let logFile = containerURL.appendingPathComponent("gost.log").path + LibgostSetLogMaxSize(2 * 1024 * 1024) + LibgostSetLogFile(logFile, nil) + LibgostSetLogLevel(level) + os_log(.default, "[GostX] logging: enabled=%{public}@ level=%{public}@", + AppGroupConfig.loggingEnabled ? "yes" : "no", level) + } +}