# NetRelay

> SSH Tunnel Manager cho macOS — viết bằng SwiftUI
> **Tác giả:** Tân Tạ
> **Phiên bản:** 3.1.0
> **Yêu cầu:** macOS 13 Ventura trở lên, Xcode 15+
> **Liên hệ / Mua license:** tanhattan0051@gmail.com

---

## Tổng quan

NetRelay là ứng dụng macOS giúp quản lý các SSH tunnel một cách trực quan. Thay vì gõ lệnh terminal mỗi lần, bạn cấu hình tunnel một lần rồi bấm Connect — app sẽ tự giữ kết nối, tự reconnect khi đứt, và hiển thị trạng thái ngay trên menu bar.

**v3.1.0 — Migration & Update:**
- **Data Migration từ Core Tunnel** — Tự động phát hiện và chuyển toàn bộ dữ liệu từ app cũ `Core Tunnel` (`typing.Core-Tunnel`) sang `NetRelay` khi khởi động lần đầu: tunnels, SSH keys, tags, proxies, rules, license/trial, settings, và Application Support files.
- **Fix Auto-Update không tự mở lại app** — Sửa lỗi update script bị indent sai (Swift multiline string), process con bị kill cùng app cha, thiếu LaunchServices registration. Thêm retry `open` 3 lần, `pgrep` chờ app quit thật sự, và `disown` để script sống sót.
- **Bảo toàn License khi đổi tên app** — Migrate `coretunnel.deviceID` → `netrelay.deviceID`, `coretunnel.trialExpiresAt` → `netrelay.trialExpiresAt` để user không bị mất trial/license.
- **Migrate Settings** — Bảo toàn `clashEnabled`, `connectOnLaunch`, `launchAtLogin`, `backupPath`, `enableAutoBackup` và tất cả @AppStorage keys.

**v3.0.2 — Fixes:**
- **Sửa lỗi mở nhiều Main Window** — Quy định cửa sổ chính thành một bản thể duy nhất (singleton), ngăn chặn việc tạo dư thừa nhiều cửa sổ thông qua phím tắt hoặc trigger ngoài ý muốn.
- **Khắc phục lỗi treo app khi Sleep Mac** — Dừng toàn bộ timer kết nối dư thừa khi gấp nắp Mac, ngăn tình trạng sinh lỗi liên tục gây nghẽn tiến trình nền.
- **Làm mượt thao tác Quit App** — Ẩn các cửa sổ hiển thị ngay tức thì khi thoát (không còn độ trễ UI), dời các tiến trình nhả Proxy như `networksetup` sang hoạt động ngầm. Xóa bỏ hiện tượng "xoay" (freeze) ứng dụng không cần thiết.

**v3.0.1 — Updates:**
- Nâng cấp ổn định chung và tương thích macOS.

**v2.4.0 — Tính năng mới:**
- **Auto-Update GitHub** — Tự động check và tải, ghi đè ứng dụng nguyên vẹn từ Private/Public GitHub Releases.
- **License Lockdown** — Cắt đứt toàn bộ kết nối Proxy, VPN, dập ClashEngine và khoá chặt giao diện tức thì khi hết hạn.
- **HWID Tracker** — Hiển thị công khai mã ngầm Hardware ID tại mục About.

**v2.3.1 — Fixes:**
- **LaunchDaemon** — mihomo chạy qua daemon, chỉ hỏi password **1 lần duy nhất** khi cài lần đầu
- **Clash ẩn hoàn toàn** — không còn toggle trong MenuBar, tự chạy ngầm
- **Fast Quit** — UI đóng ngay khi bấm Quit, cleanup chạy background
- **Fix duplicate proxy** — ClashConfigGenerator không còn generate tên proxy trùng
- **Fix IP trong Applications** — IP address lọt vào field Applications được generate đúng thành `IP-CIDR` thay vì `PROCESS-NAME`
- **Fix Edit Proxy/Rule** — bấm Edit luôn load đúng data ngay lần đầu, không bị cache

**v2.3.0 — Tính năng mới:**
- **Clash Engine (mihomo)** — tích hợp Clash Meta làm transparent proxy engine
- **TUN Mode** — intercept toàn bộ traffic ở system level, không cần config browser
- **Per-app Routing** — route theo bundle ID / process name (yêu cầu Clash Engine)
- **Auto Config Generation** — tự động generate Clash config từ tunnels + rules

**v2.2.0 — Tính năng:**
- **Proxy Manager** — tạo và quản lý SOCKS5/SOCKS4/HTTPS proxy, bind vào tunnel
- **Rules Engine** — route traffic theo domain, IP, IP range, CIDR, port
- **PAC Server** — local HTTP server tự động generate và serve PAC file
- **System Proxy Auto-Apply** — tự động set/unset macOS system proxy khi tunnel connect/disconnect

**Hỗ trợ các kiểu tunnel:**
- **Local Forward** `-L` — forward cổng local sang server
- **Remote Forward** `-R` — forward cổng remote về máy local
- **SOCKS5 Proxy** `-D` — proxy động dùng cho trình duyệt
- **Dynamic Forward** — tương tự SOCKS5

---

## Cấu trúc file

```
NetRelay/
├── Models.swift                # Tất cả data model, enum, KeychainHelper
├── TunnelStore.swift           # Lưu trữ và quản lý dữ liệu (TunnelStore, KeyManager, TagManager)
├── TunnelManager.swift         # Chạy và quản lý SSH process + PAC rebuild + Clash trigger
├── ProxyRuleModels.swift       # Data model cho Proxy và Rule
├── ProxyRuleStore.swift        # Storage/CRUD cho Proxy và Rule
├── ProxyRuleSharedTypes.swift  # UI types dùng chung (AppEntry, EntryChip, FlowLayout...)
├── ProxyEditView.swift         # UI tạo/sửa Proxy config
├── RuleEditView.swift          # UI tạo/sửa Rule (domain/IP/app routing)
├── ProxyRuleWindowController.swift  # Window + list/detail view cho Proxies & Rules
├── ProxyTrafficRouter.swift    # Match rule → SSH ProxyCommand args
├── PACServer.swift             # Local HTTP server + PAC file generator
├── SystemProxyManager.swift    # Set/unset macOS system proxy qua networksetup
├── ClashConfigGenerator.swift  # Generate Clash Meta config.yaml từ tunnels + rules
├── ClashEngine.swift           # Quản lý mihomo process lifecycle qua LaunchDaemon
├── ClashManager.swift          # Bridge TunnelManager ↔ ClashEngine (auto-start, silent)
├── mihomo                      # Clash Meta binary (bundled)
├── AppUpdater.swift            # Quản lý tiến trình cập nhật ngầm qua Web Portal
├── MigrationManager.swift      # Migration dữ liệu từ Core Tunnel sang NetRelay
├── LicenseManager.swift        # Quản lý license: Trial, Key, verify server, Lockdown
├── ContentView.swift           # Giao diện chính (cửa sổ app)
├── NetRelayApp.swift           # App entry point + Menu Bar + LicenseWindowController
├── SettingsView.swift          # Cửa sổ Settings (Cmd+,)
├── SyncManager.swift           # iCloud sync + backup
├── NetRelaySSHImporter.swift   # Import từ NetRelay SSH app
├── NetRelay.entitlements       # Quyền truy cập network/file
└── AppIcon.appiconset.zip      # Icon app tất cả kích thước
```

---

## Chi tiết từng file

---

### `Models.swift` *(Cập nhật — v2.3)*

Định nghĩa toàn bộ data model dùng xuyên suốt app.

| Type | Mô tả |
|------|-------|
| `Tunnel` | Model chính — lưu toàn bộ cấu hình 1 tunnel |
| `TunnelType` | Enum: `.localForward` `.remoteForward` `.socks5` `.dynamicForward` |
| `AuthMethod` | Enum: `.password` `.privateKey` `.agent` |
| `TunnelStatus` | Enum: `.idle` `.connecting` `.connected` `.error(String)` `.disconnected` |
| `IdentityKey` | Model cho SSH private key — passphrase lưu qua `KeychainHelper` |
| `TunnelTag` | Tag màu sắc để phân loại tunnel |
| `LogEntry` | Một dòng log với timestamp, level |
| `KeychainHelper` | Lưu/đọc/xóa passphrase SSH key từ macOS Keychain |
| `TunnelPasswordKeychain` | Lưu/đọc/xóa password SSH từ Keychain (thay vì plaintext) |

**Password auth flow:**
- `Tunnel.password` là computed property — get/set tự gọi `TunnelPasswordKeychain`
- Password **không** lưu vào `Codable`, không bao giờ xuất hiện trong UserDefaults hay JSON
- `CodingKeys` không include `password` — bảo mật hoàn toàn

---

### `ClashEngine.swift` *(Cập nhật — v2.3.1)*

Quản lý mihomo process **qua LaunchDaemon** thay vì trực tiếp.

**Thay đổi v2.3.1:**
- Bỏ hoàn toàn SUID approach (`hasRootPermission`, `requestPermissionIfNeeded`)
- Thay bằng LaunchDaemon install — hỏi password **1 lần duy nhất** lần đầu chạy
- Các lần sau: `launchctl start/stop` không cần password
- Update app không ảnh hưởng daemon (daemon độc lập với app binary)
- Health check có **retry** — thử 8 lần, mỗi lần cách 1.5 giây thay vì check 1 lần sau 2 giây

**LaunchDaemon flow:**
```
Lần đầu bật Clash
  → isDaemonInstalled() = false
  → installDaemon() — ghi plist vào /Library/LaunchDaemons/
  → AppleScript: cp + chown + chmod + launchctl load (hỏi password 1 lần)
  → launchctlStart() — launchctl start com.netrelay.mihomo

Các lần sau
  → isDaemonInstalled() = true → skip install
  → launchctlStart() trực tiếp — không hỏi password

Update app
  → Daemon vẫn còn trong /Library/LaunchDaemons/
  → Không hỏi password lại
```

**Daemon plist path:**
```
/Library/LaunchDaemons/com.netrelay.mihomo.plist
```

**Config path:**
```
~/Library/Application Support/NetRelay/clash/config.yaml
```

**Key methods:**
```swift
func start(config: String)    // Install daemon nếu cần, rồi launchctl start
func stop()                   // launchctl stop
func updateConfig(_ config: String)  // Reload config không restart (qua API)
var status: ClashEngineStatus // .stopped / .starting / .running / .error
var isAvailable: Bool         // mihomo binary có tìm thấy không
```

**Health check với retry:**
```swift
// Thử tối đa 8 lần, mỗi lần cách 1.5 giây (~12 giây tổng)
checkHealthWithRetry(attempts: 8, interval: 1.5)
```

---

### `ClashManager.swift` *(Cập nhật — v2.3.1)*

Bridge giữa `TunnelManager` và `ClashEngine`. Chạy **hoàn toàn ngầm**, không expose UI.

**Thay đổi v2.3.1:**
- `isEnabled` luôn `true` — không còn toggle, không lưu UserDefaults
- Thêm `bootEngine()` — auto-start khi app launch
- Thêm `silentBrewInstall()` — nếu mihomo chưa có, cài qua brew hoàn toàn ngầm
- Thêm `stopForTermination()` — stop engine trên background thread khi app quit

**Auto-start flow:**
```
App launch
  → bootEngine() sau 1 giây
  → mihomo đã có? → startIfNeeded()
  → Chưa có? → silentBrewInstall() → startIfNeeded()
```

**Silent brew install:**
- Chạy `brew install mihomo` hoàn toàn background
- Set `HOMEBREW_NO_AUTO_UPDATE=1` để không mất thêm thời gian
- Suppress toàn bộ stdout/stderr — user không thấy gì
- Chỉ hoạt động nếu máy đã có Homebrew; nếu không có thì skip

---

### `NetRelayApp.swift` *(Cập nhật — v2.3.1)*

**Thay đổi v2.3.1:**

Xóa `ClashEngineToggleRow` khỏi MenuBar footer — Clash chạy ngầm hoàn toàn:

```
MenuBar Popover
├── Header (tunnel count, license)
├── Connect All / Disconnect All
├── Tunnel list
├── ─────────────────
├── Proxies & Rules...          ← giữ nguyên
├── Open Main Window
├── Preferences...
├── License...
└── Quit NetRelay
```

**Fast quit** — `applicationWillTerminate` mới:
```swift
func applicationWillTerminate(_ notification: Notification) {
    // 1. Đóng tất cả window NGAY — user thấy app biến mất liền
    DispatchQueue.main.async {
        NSApp.windows.forEach { $0.orderOut(nil) }
    }
    // 2. Cleanup chạy background với timeout 3 giây
    let sem = DispatchSemaphore(value: 0)
    DispatchQueue.global(qos: .userInitiated).async {
        PACServer.shared.stop()
        SystemProxyManager.shared.removeAll(tunnelName: "App terminated", waitForCompletion: true)
        ClashManager.shared.stopForTermination()
        sem.signal()
    }
    _ = sem.wait(timeout: .now() + 3.0)
}
```

---

### `ClashConfigGenerator.swift` *(Cập nhật — v2.3.1)*

**Fix v2.3.1:**

**1. Duplicate proxy name:**
- Thêm `addedProxyNames: Set<String>` để track tên đã generate
- Bound proxy có cùng port với tunnel → skip, không generate thêm entry trùng
- `uniqueProxyName()` helper — tự append `-2`, `-3`... nếu tên trùng

**2. IP trong Applications field:**
- Trước đây: IP lọt vào `applications` array → generate `PROCESS-NAME,192.168.1.1` (sai)
- Sau fix: detect IP/CIDR/range trong applications → generate `IP-CIDR` đúng
- Tách `hostRules()` thành function riêng, dùng chung cho cả `targetHosts` và `applications`

```swift
// Trong loop applications — phân biệt IP vs process name
if isIPv4(a) {
    rulesYAML += "  - IP-CIDR,\(a)/32,\(target),no-resolve\n"
} else if a.contains("/") && a.first?.isNumber == true {
    rulesYAML += "  - IP-CIDR,\(a),\(target),no-resolve\n"
} else {
    rulesYAML += "  - PROCESS-NAME,\(a),\(target)\n"
}
```

**3. allProxyNames deduplication:**
- Dùng `seenInGroups: Set<String>` — mỗi tên chỉ được add vào proxy-groups **1 lần**

---

### `ProxyRuleWindowController.swift` *(Cập nhật — v2.3.1)*

**Fix Edit Proxy/Rule không load đúng data:**

Root cause: `.sheet(isPresented: $showEdit)` — SwiftUI reuse sheet cũ khi `showEdit` đã `true`, `@State` trong `ProxyEditView` bị cache từ lần trước.

Fix: Đổi sang `.sheet(item:)` với wrapper struct `Identifiable`:

```swift
struct EditProxyItem: Identifiable {
    let id: UUID        // proxy.id khi edit, UUID() mới khi add
    let proxy: ProxyConfig?
}

// Khi bấm Edit:
editItem = EditProxyItem(id: proxy.id, proxy: proxy)

// Sheet:
.sheet(item: $editItem) { item in
    ProxyEditView(proxy: item.proxy, tunnels: tunnels)
        .id(item.id)  // ← force recreate với đúng data
}
```

Kết quả: bấm Edit bất kỳ proxy/rule nào đều load đúng data ngay lần đầu.

---

### `ProxyRuleModels.swift` *(v2.2)*

Data model cho tính năng Proxy và Rules.

| Type | Mô tả |
|------|-------|
| `ProxyConfig` | Cấu hình 1 proxy: address, port, protocol, auth, tunnelID |
| `ProxyProtocol` | Enum: `.socks5` `.socks4` `.https` |
| `ProxyAuthentication` | Username/password cho proxy có auth |
| `AppRule` | Rule routing: applications, targetHosts, targetPorts, action |
| `RuleAction` | Enum: `.direct` `.proxy` `.block` `.tunnel` |

**Rule priority:** Rule ở vị trí cao hơn trong danh sách thắng — nếu 2 rule cùng match 1 IP, rule nào đứng trước sẽ được áp dụng.

**2 IP cùng 1 rule:** OR logic — traffic đến IP nào trong list cũng áp dụng cùng action.

---

### `ProxyRuleStore.swift` *(v2.2)*

Observable store quản lý danh sách Proxy và Rule. Persist vào `UserDefaults`.

**Key methods:**
```swift
func addProxy(_ proxy: ProxyConfig)
func updateProxy(_ proxy: ProxyConfig)
func deleteProxy(id: UUID)
func proxies(for tunnelID: UUID) -> [ProxyConfig]
func proxy(for id: UUID?) -> ProxyConfig?

func addRule(_ rule: AppRule)
func updateRule(_ rule: AppRule)
func deleteRule(id: UUID)
var activeRules: [AppRule]   // chỉ rules đang enabled, theo thứ tự
```

---

### `PACServer.swift` *(Cập nhật — v2.3)*

Local HTTP server serve PAC file + PAC generator từ Rules data.

**Kiến trúc:**
```
Tunnels (nhiều port) ──┐
Rules (domain/IP/CIDR) ─┤→ PACGenerator → pac.js → HTTP :9876
Proxies (config)       ──┘                              ↓
                                              macOS PAC URL
                                    http://127.0.0.1:9876/proxy.pac
```

**PACGenerator — các kiểu condition được generate:**

| Input | PAC condition |
|-------|--------------|
| `example.com` | `host === "example.com"` |
| `*.example.com` | `dnsDomainIs(host, ".example.com")` |
| `192.168.1.1` | IP-CIDR (qua dnsResolve local) |
| `10.0.0.0-10.255.255.255` | IP range arithmetic check |
| `192.168.0.0/24` | `isInNet(dnsResolve(host), ...)` |
| Port `80` | regex match trên URL |

---

### `SystemProxyManager.swift` *(Cập nhật — v2.3)*

Set/unset macOS system proxy dùng `networksetup` CLI.

**Key methods:**
```swift
func applyPAC(url: String)
func removeAll(tunnelName: String, waitForCompletion: Bool)
func rebuildPAC(proxyPortMap: [UUID: String])
```

---

### `TunnelManager.swift` *(Cập nhật — v2.3)*

Thêm Clash Engine integration:

```swift
// Sau khi tunnel connect
tunnelPortMap[tunnel.id] = tunnel.localPort
rebuildPACInBackground(tunnelID: tunnel.id, port: tunnel.localPort)
rebuildClash()   // trigger Clash config rebuild

// Sau khi tunnel disconnect
tunnelPortMap.removeValue(forKey: tunnelID)
rebuildClash()

// Public method
func rebuildClash()  // gọi ClashManager.rebuild() với state hiện tại
```

---

### `TunnelStore.swift`

Observable store quản lý danh sách tunnel, key, và tag. Tự động persist vào `UserDefaults`.

| Store | Key | Mô tả |
|-------|-----|-------|
| `TunnelStore` | `savedTunnelsData_v2` | Danh sách tunnels |
| `KeyManager` | `savedSSHKeys` | SSH private keys |
| `TagManager` | `savedTunnelTags` | Tags màu sắc |

---

### `LicenseManager.swift` *(v2.1)*

Quản lý license app: Trial 14 ngày và Key bản quyền. Verify với server `license.bizflycloud.net`.

**Luồng hoạt động:**

```
App khởi động
  → Có license key đã lưu?
      → Hiện .active, verify ngầm với server
  → Có trial expires cached?
      → Hiện .trial(daysLeft), verify ngầm
  → Chưa có gì?
      → Gửi HWID lên server (key = "TRIAL")
      → Server tạo record trial mới trong DB
      → Nhận về days_left + expires_formatted
      → Hiện .trial(daysLeft: X)
  → Timer 5 phút: tự gọi server → cập nhật days_left
  → Hết hạn / Thu hồi: Tự động khóa UI (LicenseWindowController), tắt PAC, gỡ System API, Force Stop Clash!
```

**HWID:** Serial number Mac thật qua IOKit (`IOPlatformSerialNumber`), fallback UUID cho máy ảo. Khách hàng có thể copy HWID này trong Preferences > About.

---

### `MigrationManager.swift` *(v3.1)*

Tự động migration dữ liệu từ app cũ "Core Tunnel" (`typing.Core-Tunnel`) sang "NetRelay" (`typing.NetRelay`).

**Dữ liệu được migrate:**

| Loại | Keys cũ | Keys mới |
|------|---------|----------|
| Tunnels | `savedTunnelsData_v2` | giữ nguyên |
| SSH Keys | `savedSSHKeys` | giữ nguyên |
| Tags | `savedTunnelTags` | giữ nguyên |
| Proxies | `com.coretunnel.proxies` | `com.netrelay.proxies` |
| Rules | `com.coretunnel.rules` | `com.netrelay.rules` |
| License Key | `coretunnel.licenseKey` | `netrelay.licenseKey` |
| Trial | `coretunnel.trialExpiresAt` | `netrelay.trialExpiresAt` |
| Device ID | `coretunnel.deviceID` | `netrelay.deviceID` |
| Settings | `clashEnabled`, `connectOnLaunch`... | giữ nguyên |
| App Support | `~/Library/Application Support/Core Tunnel/` | `~/NetRelay/` |

**Cơ chế:**
- Chạy **1 lần duy nhất** khi app khởi động lần đầu sau update
- Đọc từ `UserDefaults(suiteName: "typing.Core-Tunnel")` — domain cũ
- Copy sang `UserDefaults.standard` — domain mới
- Không ghi đè nếu dữ liệu mới đã tồn tại
- Flag `netrelay.migrationFromCoreTunnelDone_v2` ngăn chạy lại

---

### `AppUpdater.swift` *(Cập nhật — v3.1)*

Tự động kiểm tra bản cập nhật từ Web Portal và tự động cài đè app đang chạy.

**Chức năng chính:**
- Kết nối tới `https://netrelay.bizflycloud.info/api/update`.
- So sánh `version` từ server và `CFBundleShortVersionString`.
- Khi tải xong, bung `.zip` qua `ditto`, kích hoạt bash script để xóa app cũ và copy app mới.
- Gắn `xattr -cr` để bypass Gatekeeper "App is damaged".
- Tự động gọi Check Background 1 giây sau khi app vừa lên (`AppDelegate`).

**Fix v3.1 — App không tự mở lại sau update:**

| Lỗi | Fix |
|-----|-----|
| Shell script bị indent 12 spaces | Bỏ indent trong Swift multiline string |
| Script bị kill cùng app | `bash -c "nohup ... & disown"` |
| App chưa quit xong khi script chạy | `sleep 3` + `pgrep -x` loop chờ |
| LaunchServices chưa nhận app mới | Gọi `lsregister -f` trước `open` |
| `open` fail lần đầu | Retry `open` 3 lần, mỗi lần cách 2s |
| App terminate quá sớm | Delay 0.5s trước `NSApp.terminate` |

**Update log:** `/tmp/netrelay_update.log`

---

## Luồng dữ liệu

```
UserDefaults (persist)           Keychain
    ↑↓                           ↑↓
TunnelStore ──── TagManager      KeychainHelper + TunnelPasswordKeychain
    │                │                │
    ↓                ↓                ↓
ContentView                      KeyManager
    ├── SidebarView
    └── TunnelDetailView
            ├── ConfigTabView
            ├── AdvancedTabView
            ├── ProxiesTabView ──── ProxyRuleStore
            ├── RulesTabView   ──── ProxyRuleStore
            └── LogsTabView
                    ↑
              TunnelManager
                    ├── tunnelPortMap [UUID: String]
                    ├── /usr/bin/ssh [process × N]
                    ├── SSH_ASKPASS script (password + passphrase)
                    ├── rebuildPAC() ──→ PACServer
                    │                        ↓
                    │              http://127.0.0.1:9876/proxy.pac
                    │                        ↓
                    │              SystemProxyManager (PAC URL)
                    │
                    └── rebuildClash() ──→ ClashManager (auto, silent)
                                               ↓ debounce
                                         ClashConfigGenerator
                                               ↓
                                         config.yaml
                                               ↓
                                         ClashEngine
                                               ↓ launchctl
                                         mihomo (LaunchDaemon)
                                               ↓
                                         TUN interface
                                               ↓
                                    Toàn bộ traffic → route theo Rules

ProxyRuleStore
    ├── proxies: [ProxyConfig]   → ProxyEditView (sheet item fix)
    └── rules:   [AppRule]       → RuleEditView (sheet item fix)
                                 → PACGenerator
                                 → ClashConfigGenerator

LicenseManager (singleton)
    ├── verify.php (server) ←── Timer 5 phút
    ├── LicenseGateView (wrap ContentView)
    └── LicenseWindowController → LicenseActivationSheet
```

---

## Hướng dẫn setup Xcode

### 1. Thêm files vào project
Kéo tất cả file `.swift` vào Xcode project navigator.

### 2. Bundle mihomo binary

```bash
# Apple Silicon
brew install mihomo
cp /opt/homebrew/bin/mihomo "/path/to/NetRelay/NetRelay/mihomo"

# Intel Mac
cp /usr/local/bin/mihomo "/path/to/NetRelay/NetRelay/mihomo"
```

**Thêm vào Xcode:**
```
Build Phases → dấu + → New Copy Files Phase
→ Destination: Executables
→ dấu + → chọn file mihomo
```

**Run Script Phase:**
```bash
chmod +x "$BUILT_PRODUCTS_DIR/$CONTENTS_FOLDER_PATH/MacOS/mihomo"
```

### 3. Thêm IOKit Framework
```
Target → General → Frameworks, Libraries, and Embedded Content
→ Nhấn + → Tìm "IOKit.framework" → Add
```

### 4. Thêm Icon
```
Giải nén AppIcon.appiconset.zip
→ Kéo folder AppIcon.appiconset vào Assets.xcassets
```

### 5. Tắt App Sandbox
```
Target → Signing & Capabilities
→ Nhấn dấu – xoá "App Sandbox"
```

> ⚠️ TUN mode của mihomo yêu cầu App Sandbox phải tắt hoàn toàn.

### 6. Gắn Entitlements
```
Target → Build Settings
→ Tìm "Code Signing Entitlements"
→ Nhập: NetRelay/NetRelay.entitlements
```

### 7. Build & Run
```
Cmd + Shift + K  (Clean)
Cmd + B          (Build)
Cmd + R          (Run)
```

---

## Hướng dẫn sử dụng Clash Engine

Clash Engine chạy **hoàn toàn tự động** — không cần bật/tắt thủ công.

### Lần đầu sử dụng
1. Mở app, connect tunnel
2. App tự start Clash Engine
3. Popup hỏi password **1 lần duy nhất** để cài daemon
4. Từ đó Clash chạy im lặng, không bao giờ hỏi lại

### Setup routing
1. Tạo tunnel SOCKS5, localPort ví dụ `65321`
2. MenuBar → **Proxies & Rules** → tab Rules → tạo rule:
   - **Target Hosts**: `*.facebook.com`
   - **Action**: Tunnel → chọn tunnel tương ứng
3. Connect tunnel → Clash tự generate config và start
4. Toàn bộ traffic đến `facebook.com` tự đi qua tunnel

### Debug
```bash
# Xem log mihomo
tail -f /tmp/netrelay-mihomo.log

# Kiểm tra daemon
launchctl list | grep netrelay

# Xem config được generate
cat ~/Library/Application\ Support/NetRelay/clash/config.yaml
```

### So sánh PAC vs Clash Engine

| Tính năng | PAC Server | Clash Engine |
|-----------|-----------|--------------|
| Route theo domain | ✅ | ✅ |
| Route theo IP/CIDR | ✅ | ✅ |
| Route theo IP range | ✅ | ✅ |
| Route theo port | ✅ | ✅ |
| Route theo app (per-app) | ❌ | ✅ |
| Chrome follow tự động | ❌ | ✅ |
| curl / terminal | ❌ | ✅ |
| Toàn bộ traffic | ❌ | ✅ (TUN mode) |
| DNS leak protection | ❌ | ✅ (fake-ip) |
| Cần cài thêm | Không | mihomo binary |
| Hỏi password | Không | 1 lần duy nhất |

---

## Setup Server License

### 1. Upload verify.php
```
Upload verify.php lên: license.bizflycloud.net/verify.php
```

### 2. Cấu hình DB
```sql
CREATE TABLE app_licenses (
  id           INT AUTO_INCREMENT PRIMARY KEY,
  license_key  VARCHAR(100) NOT NULL UNIQUE,
  duration_days INT DEFAULT 14,
  hwid_locked  VARCHAR(200) DEFAULT NULL,
  expires_at   DATETIME DEFAULT NULL,
  is_active    TINYINT DEFAULT 1,
  created_at   DATETIME DEFAULT NOW()
);

-- Cấu hình số ngày trial
INSERT INTO app_licenses (license_key, duration_days, is_active)
VALUES ('__TRIAL_CONFIG__', 300, 1);

-- Thêm key bản quyền cho khách hàng
INSERT INTO app_licenses (license_key, duration_days, is_active)
VALUES ('CORE-XXXX-XXXX-XXXX', 365, 1);
```

### 3. Test API
```bash
curl -X POST https://license.bizflycloud.net/verify.php \
  -H "Content-Type: application/json" \
  -H "X-App-Token: APP-SECRET-2026-XYZ" \
  -d '{"license_key":"TRIAL","hwid":"TEST-HWID-001","action":"activate"}'
```

---

## Các vấn đề đã fix

| Vấn đề | Nguyên nhân | Fix |
|--------|-------------|-----|
| Exit code 255 "Broken pipe" | Gọi SSH qua `/bin/sh -c` | Gọi trực tiếp `/usr/bin/ssh` |
| Path key có `'single quotes'` | Shell escaping | Truyền qua args array |
| `BatchMode=yes` block passphrase | BatchMode tắt SSH_ASKPASS | Logic mới: chỉ bật khi key không có passphrase |
| `BatchMode=yes` khi `identityKeyID == nil` | Fallback false → bật BatchMode | Fix: không bật BatchMode khi không chọn key |
| SSH agent fail `signing failed for RSA ""` | BatchMode=yes block agent | Fix auth switch statement rõ ràng |
| Password auth fail `/usr/X11R6/bin/ssh-askpass` | SSH_ASKPASS không được set cho password | Tạo askpass script cho cả password auth |
| `-p ` với value rỗng trong SSH args | `sshPort` empty string bypass check | Trim whitespace + chỉ thêm `-p` khi field không trống |
| Dot màu không hiển thị menu bar | `.menu` style strip màu | Dùng `.window` style |
| Port hiển thị `65.321` thay vì `65321` | `format: .number` dùng locale VN | Dùng string binding + filter digits |
| Proxy config không apply traffic | UI lưu config nhưng không set system proxy | Thêm `SystemProxyManager` tự apply |
| Nhiều tunnel ghi đè nhau | Single SOCKS5 proxy | PAC file routing theo rules |
| PAC `Enabled: No` dù tunnel connected | `rebuildPAC` check `activeRules.isEmpty` | Luôn apply PAC khi có tunnel active |
| PAC không clear khi tắt app | `removeAll` async, app exit trước | Sync cleanup + `waitForCompletion: true` |
| SOCKS5 còn trên USB LAN, VPN sau khi tắt | `activeInterfaces()` bỏ sót interface | Dùng `allInterfaces()` trong `removeAll()` |
| `setautoproxyurl "" ` lỗi | macOS không accept URL rỗng | Dùng `setautoproxystate off` trước |
| `dnsDomainIs` không match exact domain | Hàm chỉ match subdomain | Dùng `host ===` cho exact match |
| PAC block browser khi resolve | `dnsResolve()` ở đầu PAC | Chỉ dùng dnsResolve trong IP range/CIDR |
| Chrome không follow system PAC | Chrome có proxy engine riêng | Tích hợp Clash Engine TUN mode |
| Password lưu plaintext trong UserDefaults | `password` trong CodingKeys | Chuyển sang `TunnelPasswordKeychain` |
| ContentView tạo instance mới của managers | `@StateObject` thay vì `@EnvironmentObject` | Đổi sang `@EnvironmentObject` |
| `Type 'Tunnel' does not conform to Hashable` | `TunnelStatus` có associated value | Tách status ra khỏi Tunnel |
| `Invalid redeclaration of 'KeyManager'` | File trùng | Xóa file riêng, dùng class trong TunnelStore |
| `LicenseActivationSheet` không mở từ MenuBar | MenuBarExtra không hỗ trợ `.sheet` | Dùng `LicenseWindowController` (NSWindow) |
| License hiển thị sai số ngày | `Calendar.dateComponents` làm tròn xuống | Dùng `ceil(seconds / 86400)` |
| Trial lạm dụng — xóa app reset lại | Trial local (UserDefaults) | Trial lưu DB server theo HWID |
| `init(contentsOfFile:)` deprecated | API cũ macOS 15 | Dùng `String(contentsOf: url, encoding:)` |
| Clash hỏi password mỗi lần start | SUID bit reset khi Xcode rebuild | Chuyển sang LaunchDaemon — hỏi 1 lần duy nhất |
| Clash toggle hiển thị trong MenuBar | `ClashEngineToggleRow` trong footer | Xóa UI toggle, Clash chạy ngầm hoàn toàn |
| App lag khi bấm Quit | `applicationWillTerminate` block main thread | `orderOut` windows ngay + cleanup background + semaphore 3s |
| Clash generate duplicate proxy name | Bound proxy và tunnel proxy cùng port được add 2 lần | Thêm `addedProxyNames: Set<String>` — track và skip duplicate |
| IP trong Applications field bị generate sai | IP lọt vào `applications` → `PROCESS-NAME` rule | Detect IP/CIDR trong applications → generate `IP-CIDR` đúng |
| Bấm Edit proxy/rule hiển thị New thay vì data cũ | `.sheet(isPresented:)` reuse view, `@State` bị cache | Đổi sang `.sheet(item:)` + `.id(item.id)` — force recreate |
| `mihomo failed to start after multiple attempts` | Health check sau 2s quá sớm, `launchctl start` chưa load daemon | `launchctl load` trước rồi `start`, retry health check 8 lần × 1.5s |
| Mở nhiều cửa sổ UI khi gọi "Open Main Window" | `WindowGroup` hỗ trợ đa cửa sổ được gọi liên tục | Chuyển sang dùng cấu trúc `Window("...", id: "main")` singleton |
| App treo và sinh lỗi mạng liên tiếp khi Mac Sleep | Bộ định thời kết nối liên tục thử kết nối lại khi mất mạng | Bắt sự kiện `willSleep`, chặn Timer và tạm dừng active procesos |
| Quá trình thoát app bị gián đoạn (beachball xoay) | Networksetup chạy đồng bộ trên Main Thread cản trở UI ẩn | Chuyển networksetup sang Background Thread và buộc Window `orderOut` ngay |
| Mất toàn bộ config khi đổi tên app | Bundle ID thay đổi (`typing.Core-Tunnel` → `typing.NetRelay`), UserDefaults nằm ở domain cũ | `MigrationManager` đọc domain cũ, copy sang domain mới khi khởi động lần đầu |
| App không tự mở lại sau auto-update | Shell script bị indent sai + process con bị kill cùng app | Bỏ indent, dùng `bash -c + disown`, `pgrep` chờ quit, `lsregister` + retry `open` 3 lần |
| License/Trial bị reset khi update app | Keys `coretunnel.*` không được migrate sang `netrelay.*` | Migrate tất cả license keys trong MigrationManager |

---

*NetRelay v3.1.0 — Developed by Tân Tạ — 2026*
*License & Support: tanhattan0051@gmail.com*
