Skip to content
Draft
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
26 changes: 20 additions & 6 deletions docs/local-web-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,29 @@ wiki content. The app registers or repairs that helper through native setup UI
and macOS Login Items & Extensions approval. The same flow trusts the local
Caddy CA in the user's login keychain. Uninstall removes both.

The canonical product URL is:
The default browser-open product URL is:

```text
https://wiki.1context.localhost/your-context
https://localhost/your-context
```

High-port HTTP (`http://wiki.1context.localhost:<port>/your-context`) remains a
test and development harness mode only. Product code should not silently fall
back to it when the local HTTPS setup is missing.
The branded alias `https://wiki.1context.localhost/your-context` is still served
by Caddy and reported by diagnose, but product UI must not depend on that
multi-label `.localhost` name for either app readiness or default browser-open
behavior. Some macOS networking clients and browsers do not resolve it
consistently.

Browser-visible high-port HTTPS
(`https://localhost:<port>/your-context` or
`https://wiki.1context.localhost:<port>/your-context`) remains a test and
development harness mode only. Product UI should not silently fall back to it
when the local HTTPS setup is missing.

The app's internal health probe uses literal loopback
`https://127.0.0.1:<port>/__1context/health` against the user-owned Caddy edge.
That keeps app readiness off local DNS entirely. Diagnose additionally probes
`https://localhost/__1context/health` through the privileged proxy and the
branded host health URL, but those probes are classified separately.

The Swift daemon owns the local dynamic wiki API:

Expand Down Expand Up @@ -103,7 +117,7 @@ should still load, with dynamic API calls degrading cleanly.

The local adapter must not leak into the web contract:

- no browser-visible socket paths or loopback-only URLs
- no browser-visible socket paths or high-port backend URLs
- no Caddy-specific behavior required by browser JavaScript
- no render-on-request behavior from API routes
- local-only capabilities must be explicit in API capability responses
Expand Down
2 changes: 1 addition & 1 deletion docs/macos-app-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ stateDiagram-v2
WikiOpen --> PermissionsUI: setup later becomes stale or missing
```

The required launch gate is Local Wiki Access because the app's primary wiki URL is `https://wiki.1context.localhost/your-context`. Future sensitive permissions should be added only with the feature that needs them, plus policy and tests for the exact user-facing prompt.
The required launch gate is Local Wiki Access because the app's primary wiki URL is `https://localhost/your-context`. The branded alias `https://wiki.1context.localhost/your-context` is served for diagnostics and compatibility observation, but the app does not depend on it for readiness or browser-open behavior. Future sensitive permissions should be added only with the feature that needs them, plus policy and tests for the exact user-facing prompt.

## Smoke Policy

Expand Down
3 changes: 2 additions & 1 deletion docs/macos-release-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ This is the current operating doc for shipping the 1Context macOS app.
train. Prototype, private, and official `.app`/DMG builds must bundle their
runtime inputs instead of resolving tools from Homebrew or host `PATH`.
- Update engine: Sparkle from the installed `/Applications/1Context.app`.
- Canonical wiki URL: `https://wiki.1context.localhost/your-context`.
- Default wiki URL: `https://localhost/your-context`.
- Branded local alias: `https://wiki.1context.localhost/your-context`.
- Required first setup: Local Wiki Access through native app setup.
- User content root: `~/1Context`.
- App machinery root: `~/Library/Application Support/1Context`.
Expand Down
6 changes: 6 additions & 0 deletions macos/Sources/OneContextCLI/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@ struct OneContextCLI {
print(" URL Mode: \(diagnostics.urlMode)")
print(" Trust Mode: \(diagnostics.trustMode)")
print(" Privileged Bind Required: \(yesNo(diagnostics.privilegedBindRequired))")
print(" Readiness Probe URL: \(diagnostics.readinessProbeURL)")
print(" Readiness Probe Health: \(diagnostics.readinessProbeHealth)")
print(" Privileged Proxy Probe URL: \(diagnostics.privilegedProxyProbeURL)")
print(" Privileged Proxy Probe Health: \(diagnostics.privilegedProxyProbeHealth)")
print(" Branded Host Probe URL: \(diagnostics.brandedProbeURL)")
print(" Branded Host Probe Health: \(diagnostics.brandedProbeHealth)")
for line in LocalWebSetupDiagnostics.render(diagnostics.setup, redact: { displayPath($0, redact: redact) }) {
print(line)
}
Expand Down
106 changes: 96 additions & 10 deletions macos/Sources/OneContextLocalWeb/LocalWeb.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ public struct LocalWebDiagnostics: Codable, Equatable, Sendable {
public var trustMode: String
public var privilegedBindRequired: Bool
public var setup: LocalWebSetupSnapshot
public var readinessProbeURL: String
public var readinessProbeHealth: String
public var privilegedProxyProbeURL: String
public var privilegedProxyProbeHealth: String
public var brandedProbeURL: String
public var brandedProbeHealth: String
public var apiURL: String
public var apiHealth: String
public var apiPort: Int
Expand Down Expand Up @@ -60,6 +66,12 @@ public struct LocalWebDiagnostics: Codable, Equatable, Sendable {
trustMode: String,
privilegedBindRequired: Bool,
setup: LocalWebSetupSnapshot,
readinessProbeURL: String,
readinessProbeHealth: String,
privilegedProxyProbeURL: String,
privilegedProxyProbeHealth: String,
brandedProbeURL: String,
brandedProbeHealth: String,
apiURL: String,
apiHealth: String,
apiPort: Int,
Expand All @@ -86,6 +98,12 @@ public struct LocalWebDiagnostics: Codable, Equatable, Sendable {
self.trustMode = trustMode
self.privilegedBindRequired = privilegedBindRequired
self.setup = setup
self.readinessProbeURL = readinessProbeURL
self.readinessProbeHealth = readinessProbeHealth
self.privilegedProxyProbeURL = privilegedProxyProbeURL
self.privilegedProxyProbeHealth = privilegedProxyProbeHealth
self.brandedProbeURL = brandedProbeURL
self.brandedProbeHealth = brandedProbeHealth
self.apiURL = apiURL
self.apiHealth = apiHealth
self.apiPort = apiPort
Expand All @@ -111,11 +129,17 @@ public struct LocalWebDiagnostics: Codable, Equatable, Sendable {

public enum LocalWebDefaults {
public static let wikiHost = "wiki.1context.localhost"
public static let browserHost = "localhost"
public static let bindHost = "127.0.0.1"
// Keep app-internal readiness on literal loopback. The browser URL uses
// localhost because multi-label .localhost handling varies by macOS client.
public static let healthHost = bindHost
public static let privilegedProxyHealthHost = browserHost
public static let wikiPort = 39191
public static let wikiAPIPort = 39192
public static let wikiRoute = "/your-context"
public static let defaultWikiURL = "https://\(wikiHost)\(wikiRoute)"
public static let defaultWikiURL = "https://\(browserHost)\(wikiRoute)"
public static let brandedWikiURL = "https://\(wikiHost)\(wikiRoute)"
}

public enum LocalWebURLMode: String, Codable, Sendable {
Expand Down Expand Up @@ -288,10 +312,18 @@ public struct CaddyConfig: Equatable, Sendable {
}

public var url: String {
"https://\(host)\(LocalWebDefaults.wikiRoute)"
LocalWebDefaults.defaultWikiURL
}

public var healthURL: URL {
URL(string: "https://\(LocalWebDefaults.healthHost):\(port)/__1context/health")!
}

public var privilegedProxyHealthURL: URL {
URL(string: "https://\(LocalWebDefaults.privilegedProxyHealthHost)/__1context/health")!
}

public var brandedHealthURL: URL {
URL(string: "https://\(host)/__1context/health")!
}

Expand All @@ -307,7 +339,7 @@ public struct CaddyConfig: Equatable, Sendable {
auto_https disable_redirects
}

https://\(host):\(port) {
\(siteAddresses()) {
bind \(bindHost)
root * "\(escape(siteRoot.path))"

Expand Down Expand Up @@ -352,6 +384,15 @@ public struct CaddyConfig: Equatable, Sendable {
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
}

private func siteAddresses() -> String {
let addresses = [
"https://\(host):\(port)",
"https://\(LocalWebDefaults.healthHost):\(port)",
"https://\(LocalWebDefaults.privilegedProxyHealthHost):\(port)"
]
return Array(Set(addresses)).sorted().joined(separator: ", ")
}
}

private struct CaddyState: Codable {
Expand Down Expand Up @@ -465,20 +506,31 @@ public final class CaddyManager: @unchecked Sendable {
rootCertificateSHA256: fingerprints.sha256,
backendHost: LocalWebDefaults.bindHost,
backendPort: port,
publicHost: host,
publicHost: LocalWebDefaults.browserHost,
publicPort: LocalWebSetupConstants.privilegedHTTPSPort
)
}

public func diagnostics() -> LocalWebDiagnostics {
let caddy = (try? caddyExecutable()) ?? URL(fileURLWithPath: "")
let bundled = bundledCaddyURL()
let config = caddyConfig()
let setup = localWebSetupSnapshot()
let readinessProbeHealth = setup.ready ? probeHealth(config.healthURL) : "setup required"
let privilegedProxyProbeHealth = setup.ready ? probeHealth(config.privilegedProxyHealthURL) : "setup required"
let brandedProbeHealth = setup.ready ? probeHealth(config.brandedHealthURL) : "setup required"
return LocalWebDiagnostics(
snapshot: status(),
urlMode: urlMode.rawValue,
trustMode: urlMode.trustMode,
privilegedBindRequired: urlMode.privilegedBindRequired,
setup: localWebSetupSnapshot(),
setup: setup,
readinessProbeURL: config.healthURL.absoluteString,
readinessProbeHealth: readinessProbeHealth,
privilegedProxyProbeURL: config.privilegedProxyHealthURL.absoluteString,
privilegedProxyProbeHealth: privilegedProxyProbeHealth,
brandedProbeURL: config.brandedHealthURL.absoluteString,
brandedProbeHealth: brandedProbeHealth,
apiURL: apiConfig().healthURL.absoluteString,
apiHealth: WikiLocalAPIProbe.health(config: apiConfig()),
apiPort: apiConfig().port,
Expand Down Expand Up @@ -753,25 +805,59 @@ public final class CaddyManager: @unchecked Sendable {
}

private func healthOK(_ url: URL) -> Bool {
probeHealth(url) == "OK"
}

private func probeHealth(_ url: URL) -> String {
var request = URLRequest(url: url)
request.timeoutInterval = 0.75
let semaphore = DispatchSemaphore(value: 0)
nonisolated(unsafe) var ok = false
let task = URLSession.shared.dataTask(with: request) { data, _, _ in
nonisolated(unsafe) var health = "no response"
let task = URLSession.shared.dataTask(with: request) { data, _, error in
defer { semaphore.signal() }
if let error {
health = Self.describeProbeError(error)
return
}
guard let data,
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else {
health = "invalid response"
return
}
ok = object["status"] as? String == "ok"
health = object["status"] as? String == "ok" ? "OK" : "unhealthy response"
}
task.resume()
if semaphore.wait(timeout: .now() + 1) == .timedOut {
task.cancel()
return false
return "timeout"
}
return health
}

private static func describeProbeError(_ error: Error) -> String {
let nsError = error as NSError
guard nsError.domain == NSURLErrorDomain else {
return error.localizedDescription
}
switch nsError.code {
case NSURLErrorCannotFindHost, NSURLErrorDNSLookupFailed:
return "dns failed"
case NSURLErrorCannotConnectToHost:
return "connection failed"
case NSURLErrorTimedOut:
return "timeout"
case NSURLErrorSecureConnectionFailed,
NSURLErrorServerCertificateHasBadDate,
NSURLErrorServerCertificateUntrusted,
NSURLErrorServerCertificateHasUnknownRoot,
NSURLErrorServerCertificateNotYetValid,
NSURLErrorClientCertificateRejected,
NSURLErrorClientCertificateRequired:
return "tls failed"
default:
return "url error \(nsError.code)"
}
return ok
}

private func readState() -> CaddyState? {
Expand Down
23 changes: 17 additions & 6 deletions macos/Tests/OneContextLocalWebTests/LocalWebTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,17 @@ final class LocalWebTests: XCTestCase {
XCTAssertTrue(text.contains("admin off"))
XCTAssertTrue(text.contains("skip_install_trust"))
XCTAssertTrue(text.contains("auto_https disable_redirects"))
XCTAssertTrue(text.contains("https://wiki.1context.localhost:39191 {"))
XCTAssertTrue(text.contains("https://127.0.0.1:39191"))
XCTAssertTrue(text.contains("https://localhost:39191"))
XCTAssertTrue(text.contains("https://wiki.1context.localhost:39191"))
XCTAssertTrue(text.contains("bind 127.0.0.1"))
XCTAssertTrue(text.contains("tls internal"))
XCTAssertFalse(text.contains("auto_https off"))
XCTAssertTrue(text.contains(":39191"))
XCTAssertEqual(config.url, "https://wiki.1context.localhost/your-context")
XCTAssertEqual(config.healthURL.absoluteString, "https://wiki.1context.localhost/__1context/health")
XCTAssertEqual(config.url, "https://localhost/your-context")
XCTAssertEqual(config.healthURL.absoluteString, "https://127.0.0.1:39191/__1context/health")
XCTAssertEqual(config.privilegedProxyHealthURL.absoluteString, "https://localhost/__1context/health")
XCTAssertEqual(config.brandedHealthURL.absoluteString, "https://wiki.1context.localhost/__1context/health")
}

func testDefaultURLModeRequiresProfessionalLocalHTTPSSetup() {
Expand All @@ -32,7 +36,8 @@ final class LocalWebTests: XCTestCase {
)

XCTAssertEqual(mode, .localHTTPSPortless)
XCTAssertEqual(LocalWebDefaults.defaultWikiURL, "https://wiki.1context.localhost/your-context")
XCTAssertEqual(LocalWebDefaults.defaultWikiURL, "https://localhost/your-context")
XCTAssertEqual(LocalWebDefaults.brandedWikiURL, "https://wiki.1context.localhost/your-context")
XCTAssertEqual(config.url, LocalWebDefaults.defaultWikiURL)
}

Expand Down Expand Up @@ -94,6 +99,12 @@ final class LocalWebTests: XCTestCase {
XCTAssertTrue(diagnostics.caddyfilePath.hasSuffix("Application Support/1Context/local-web/caddy/Caddyfile"))
XCTAssertEqual(diagnostics.apiPort, LocalWebDefaults.wikiAPIPort)
XCTAssertTrue(diagnostics.apiStatePath.hasSuffix("Application Support/1Context/local-web/wiki-browser-state.json"))
XCTAssertEqual(diagnostics.readinessProbeURL, "https://127.0.0.1:39191/__1context/health")
XCTAssertEqual(diagnostics.readinessProbeHealth, "setup required")
XCTAssertEqual(diagnostics.privilegedProxyProbeURL, "https://localhost/__1context/health")
XCTAssertEqual(diagnostics.privilegedProxyProbeHealth, "setup required")
XCTAssertEqual(diagnostics.brandedProbeURL, "https://wiki.1context.localhost/__1context/health")
XCTAssertEqual(diagnostics.brandedProbeHealth, "setup required")
}

func testDiagnosticsReportsLocalHTTPSURLMode() throws {
Expand All @@ -113,7 +124,7 @@ final class LocalWebTests: XCTestCase {

let diagnostics = manager.diagnostics()

XCTAssertEqual(diagnostics.snapshot.url, "https://wiki.1context.localhost/your-context")
XCTAssertEqual(diagnostics.snapshot.url, "https://localhost/your-context")
XCTAssertEqual(diagnostics.urlMode, "local-https-portless")
XCTAssertEqual(diagnostics.trustMode, "local-ca-required")
XCTAssertTrue(diagnostics.privilegedBindRequired)
Expand All @@ -137,7 +148,7 @@ final class LocalWebTests: XCTestCase {

XCTAssertFalse(snapshot.running)
XCTAssertEqual(snapshot.health, "setup required")
XCTAssertEqual(snapshot.url, "https://wiki.1context.localhost/your-context")
XCTAssertEqual(snapshot.url, "https://localhost/your-context")
XCTAssertEqual(snapshot.lastError, "Local web setup required: Local HTTPS helper, Local certificate trust")
}

Expand Down
10 changes: 8 additions & 2 deletions macos/Tests/OneContextSetupTests/AppSetupTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ final class AppSetupTests: XCTestCase {
trustMode: setup.urlMode == LocalWebURLMode.localHTTPSPortless.rawValue ? "local-ca-required" : "none",
privilegedBindRequired: setup.urlMode == LocalWebURLMode.localHTTPSPortless.rawValue,
setup: setup,
readinessProbeURL: "https://127.0.0.1:39191/__1context/health",
readinessProbeHealth: setup.ready ? "not running" : "setup required",
privilegedProxyProbeURL: "https://localhost/__1context/health",
privilegedProxyProbeHealth: setup.ready ? "not running" : "setup required",
brandedProbeURL: "https://wiki.1context.localhost/__1context/health",
brandedProbeHealth: setup.ready ? "not running" : "setup required",
apiURL: "http://127.0.0.1:39192/__1context/api/health",
apiHealth: "not running",
apiPort: 39192,
Expand All @@ -140,11 +146,11 @@ final class AppSetupTests: XCTestCase {

private func localHTTPSSetup(ready: Bool, installedProxySHA256: String? = nil) -> LocalWebSetupSnapshot {
LocalWebSetupSnapshot.localHTTPSPortless(
targetURL: "https://wiki.1context.localhost/your-context",
targetURL: LocalWebDefaults.defaultWikiURL,
state: LocalWebSetupState(
label: LocalWebSetupConstants.proxyLabel,
targetHost: LocalWebDefaults.wikiHost,
targetURL: "https://wiki.1context.localhost/your-context",
targetURL: LocalWebDefaults.defaultWikiURL,
backendHost: LocalWebDefaults.bindHost,
backendPort: LocalWebDefaults.wikiPort,
privilegedPort: LocalWebSetupConstants.privilegedHTTPSPort,
Expand Down
Loading