Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,6 @@ Config/Signing.local.xcconfig
!.env.example
Secrets.xcconfig
GoogleService-Info.plist

# Generated by scripts/bootstrap_tailscalekit.sh
project.tailscale.yml
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ the text as the git tag message and the GitHub Release notes.

### Added

- Private tailnet inside Codeg: scan the desktop Private QR, sign in once in
Safari, then talk to `*.ts.net` over official TailscaleKit. No Tailscale.app
on the phone. Build with `scripts/bootstrap_tailscalekit.sh`.

### Changed

- Apple signing now uses an ignored local configuration instead of a committed
Expand Down
2 changes: 2 additions & 0 deletions CodegiOS/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ final class AppModel {
guard oldValue != selectedServerID else { return }
resetServerScopedState()
UserDefaults.standard.set(selectedServerID?.uuidString, forKey: Self.lastServerKey)
Task { await TailnetSession.shared.prepare(for: selectedServer?.baseURL) }
}
}

Expand Down Expand Up @@ -67,6 +68,7 @@ final class AppModel {
// anymore — the app must come up already pointed at a server.
let persisted = UserDefaults.standard.string(forKey: Self.lastServerKey).flatMap(UUID.init)
self.selectedServerID = store.servers.first { $0.id == persisted }?.id ?? store.servers.first?.id
Task { await TailnetSession.shared.prepare(for: selectedServer?.baseURL) }
}

var selectedServer: ServerProfile? {
Expand Down
14 changes: 11 additions & 3 deletions CodegiOS/Features/Projects/Terminal/TerminalSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,17 @@ final class TerminalSession {
private func openSocket(id: String) {
// Guard against a stale bootstrap/reconnect resurrecting a superseded id.
guard isCurrent(id) else { return }
let socket = TerminalSocket(baseURL: client.baseURL, token: client.token)
runtime.replaceSocket(socket)
socket.start()
Task { [weak self] in
guard let self, self.isCurrent(id) else { return }
let socket = TerminalSocket(
baseURL: self.client.baseURL,
token: self.client.token,
session: await self.client.liveSession()
)
guard self.isCurrent(id) else { return }
self.runtime.replaceSocket(socket)
socket.start()
}
// Per-iteration `weak self`: the loop must NOT hold `self` across `await`.
// A running `self.consume()` would retain the session for the socket's whole
// lifetime, so `deinit` (which kills the PTY) would never fire while a shell
Expand Down
62 changes: 11 additions & 51 deletions CodegiOS/Features/Servers/ServerEditorModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -220,61 +220,21 @@ final class ServerEditorModel {
// MARK: - QR scan

/// Apply a value decoded from a server QR code, filling the editable fields.
///
/// codeg's desktop encodes the bare `http://host:port` address, so the common
/// case is a plain URL → the URL field. We're forgiving beyond that:
/// - A JSON payload (`{"url"|"address"|"server", "token"?, "name"?}`) fills the
/// matching fields.
/// - A plain address may carry a `?token=…` query, which is split out into the
/// token field rather than left in the persisted URL.
///
/// Returns `true` when at least a usable, validated URL was extracted (and
/// clears any stale test result); `false` if the payload isn't a server
/// address, so the sheet can show "not a valid server QR".
/// Desktop Private mode sends JSON `{url, token, mode:"private"}`.
@discardableResult
func applyScanned(_ raw: String) -> Bool {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }

// 1) JSON payload (defensive — codeg encodes a bare URL, but be forgiving).
if let data = trimmed.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
let candidate = (object["url"] ?? object["address"] ?? object["server"]) as? String
guard let candidate, let normalized = Self.normalizedURL(from: candidate) else { return false }
urlString = normalized.absoluteString
if let name = (object["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
!name.isEmpty, trimmedName.isEmpty {
self.name = name
}
if let token = (object["token"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty {
self.token = token
}
fieldsChanged()
return true
guard let pairing = CodegPairingParser.parse(raw) else { return false }
urlString = pairing.url.absoluteString
if let name = pairing.name, !name.isEmpty, trimmedName.isEmpty {
self.name = name
}
if let token = pairing.token, !token.isEmpty {
self.token = token
}
if pairing.mode == .privateTailnet {
Task { await TailnetSession.shared.prepare(for: pairing.url) }
}

// 2) Plain address, possibly with a `?token=` query item.
let (address, scannedToken) = Self.splitToken(from: trimmed)
guard let normalized = Self.normalizedURL(from: address) else { return false }
urlString = normalized.absoluteString
if let scannedToken { self.token = scannedToken }
fieldsChanged()
return true
}

/// Pull an optional `token` query item out of an address, returning the
/// address without it and the token (if present and non-empty).
private static func splitToken(from raw: String) -> (address: String, token: String?) {
guard var components = URLComponents(string: raw),
let items = components.queryItems, !items.isEmpty else {
return (raw, nil)
}
let token = items.first { $0.name.lowercased() == "token" }?.value
let remaining = items.filter { $0.name.lowercased() != "token" }
components.queryItems = remaining.isEmpty ? nil : remaining
let address = components.string ?? raw
let cleanedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines)
return (address, (cleanedToken?.isEmpty == false) ? cleanedToken : nil)
}
}
4 changes: 1 addition & 3 deletions CodegiOS/Features/Servers/ServerEditorSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -190,9 +190,7 @@ struct ServerEditorSheet: View {
dismiss()
}

/// Fill the address from a scanned QR payload, or surface an error if it
/// isn't a server address. The token still has to be entered by hand (codeg's
/// QR carries only the URL).
/// Fill the address (and token, when the desktop QR includes it).
private func handleScanned(_ code: String) {
if !model.applyScanned(code) {
scanError = "That QR code isn’t a codeg server address. Make sure you’re scanning the server URL QR from codeg."
Expand Down
18 changes: 15 additions & 3 deletions CodegiOS/Features/SessionDetail/SessionDetailViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -891,7 +891,11 @@ final class SessionDetailViewModel {
streamReconnects = 0 // fresh send → fresh reconnect budget
streamGeneration &+= 1
let generation = streamGeneration
let newStream = EventStream(baseURL: client.baseURL, token: client.token)
let newStream = EventStream(
baseURL: client.baseURL,
token: client.token,
session: await client.liveSession()
)
stream = newStream
newStream.start()

Expand Down Expand Up @@ -1070,7 +1074,11 @@ final class SessionDetailViewModel {
closeStream()
streamGeneration &+= 1
let generation = streamGeneration
let newStream = EventStream(baseURL: client.baseURL, token: client.token)
let newStream = EventStream(
baseURL: client.baseURL,
token: client.token,
session: await client.liveSession()
)
stream = newStream
newStream.start()
consumerTask = Task { [weak self] in
Expand Down Expand Up @@ -1463,7 +1471,11 @@ final class SessionDetailViewModel {
guard let self, !Task.isCancelled,
self.liveTurn === live, self.isTurnActive,
generation == self.streamGeneration else { return }
let newStream = EventStream(baseURL: self.client.baseURL, token: self.client.token)
let newStream = EventStream(
baseURL: self.client.baseURL,
token: self.client.token,
session: await self.client.liveSession()
)
self.stream = newStream
newStream.start()
self.consumerTask = Task { [weak self] in
Expand Down
10 changes: 9 additions & 1 deletion CodegiOS/Networking/CodegClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ struct CodegClient: Sendable {

// MARK: - Endpoints

/// WebSocket session that uses the embedded tailnet when the host is `*.ts.net`.
func liveSession() async -> URLSession {
await TailnetSession.shared.session(for: baseURL, fallback: session)
}

/// Validate connectivity + auth for a server profile. Uses the snappier read
/// session (15s) so a dead LAN host surfaces as offline in ~15s rather than
/// the 30s default — this endpoint normally answers in well under a second,
Expand Down Expand Up @@ -368,7 +373,10 @@ struct CodegClient: Sendable {
/// body — used for settings objects that must preserve snake_case keys the
/// shared encoder/decoder would otherwise mangle (e.g. delegation settings).
func send(_ path: String, rawBody: Data, session: URLSession? = nil) async throws -> Data {
let session = session ?? self.session
let session = await TailnetSession.shared.session(
for: baseURL,
fallback: session ?? self.session
)
let url = baseURL.appendingPathComponent("api").appendingPathComponent(path)
var request = URLRequest(url: url)
request.httpMethod = "POST"
Expand Down
68 changes: 68 additions & 0 deletions CodegiOS/Networking/CodegPairing.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import Foundation

/// Desktop Web Service QR payload. Keep in lock-step with
/// `src/lib/codeg-pairing.ts` in the Codeg desktop repo.
struct CodegPairing: Equatable, Sendable {
enum Mode: String, Sendable {
case local
case privateTailnet = "private"
case publicInternet = "public"
}

var url: URL
var token: String?
var name: String?
var mode: Mode
}

enum CodegPairingParser {
static func parse(_ raw: String) -> CodegPairing? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }

if let data = trimmed.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
let candidate = (object["url"] ?? object["address"] ?? object["server"]) as? String
guard let candidate, let url = Self.url(from: candidate) else { return nil }
let token = (object["token"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let name = (object["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let mode = parseMode(object["mode"] as? String, url: url)
return CodegPairing(
url: url,
token: (token?.isEmpty == false) ? token : nil,
name: (name?.isEmpty == false) ? name : nil,
mode: mode
)
}

let (address, token) = splitToken(from: trimmed)
guard let url = Self.url(from: address) else { return nil }
return CodegPairing(url: url, token: token, name: nil, mode: parseMode(nil, url: url))
}

private static func parseMode(_ raw: String?, url: URL) -> CodegPairing.Mode {
switch raw?.lowercased() {
case "private": return .privateTailnet
case "public": return .publicInternet
case "local": return .local
default:
return PrivateHost.needsEmbeddedTailnet(url) ? .privateTailnet : .local
}
}

private static func url(from raw: String) -> URL? {
ServerEditorModel.normalizedURL(from: raw)
}

private static func splitToken(from raw: String) -> (String, String?) {
guard var components = URLComponents(string: raw),
let items = components.queryItems, !items.isEmpty else {
return (raw, nil)
}
let token = items.first { $0.name.lowercased() == "token" }?.value
let remaining = items.filter { $0.name.lowercased() != "token" }
components.queryItems = remaining.isEmpty ? nil : remaining
let cleaned = token?.trimmingCharacters(in: .whitespacesAndNewlines)
return (components.string ?? raw, (cleaned?.isEmpty == false) ? cleaned : nil)
}
}
9 changes: 9 additions & 0 deletions CodegiOS/Networking/PrivateHost.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Foundation

enum PrivateHost {
/// Official Tailscale Serve / MagicDNS hosts. These are tailnet-only.
static func needsEmbeddedTailnet(_ url: URL) -> Bool {
let host = (url.host ?? "").lowercased()
return host.hasSuffix(".ts.net")
}
}
Loading