Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
4079ac3
feat(web): add annual Pro pricing
lawrencecchen Jul 30, 2026
a61a0ab
fix(web): toggle pricing without navigation
lawrencecchen Jul 30, 2026
32dfdac
feat(web): refine annual pricing presentation
lawrencecchen Jul 30, 2026
5a7d0aa
fix(web): use cmux product blue for annual savings
lawrencecchen Jul 31, 2026
d1efb9c
fix: include working cmux theme picker
lawrencecchen Jul 31, 2026
3b8ee71
chore: update Ghostty theme picker base
lawrencecchen Jul 31, 2026
2b337b6
Merge remote-tracking branch 'origin/main' into feat-annual-pricing
lawrencecchen Jul 31, 2026
9a511d9
fix(web): adapt product blue for theme contrast
lawrencecchen Jul 31, 2026
d78a78c
chore: update Ghostty theme picker base
lawrencecchen Jul 31, 2026
23ff3aa
Merge remote-tracking branch 'origin/main' into feat-annual-pricing
lawrencecchen Jul 31, 2026
419df25
test(web): reproduce stale Pagefind trace
lawrencecchen Jul 31, 2026
28cc907
fix(web): build Pagefind before Next tracing
lawrencecchen Jul 31, 2026
88e5c16
test(web): cover annual Team pricing
lawrencecchen Jul 31, 2026
bdc02d2
feat(web): add annual Team pricing
lawrencecchen Jul 31, 2026
8f09503
test: reject external browser intent from untrusted sites
lawrencecchen Jul 31, 2026
4ca3781
fix: trust-gate external browser handoffs
lawrencecchen Jul 31, 2026
d8cf605
test(web): reject invalid billing intervals
lawrencecchen Jul 31, 2026
83c96b4
fix(web): reject invalid billing intervals
lawrencecchen Jul 31, 2026
17edc2d
Merge remote-tracking branch 'origin/main' into feat-annual-pricing
lawrencecchen Jul 31, 2026
f0f88f7
Pin GhosttyKit for pricing theme picker
lawrencecchen Jul 31, 2026
681e5de
Harden annual pricing integration
lawrencecchen Jul 31, 2026
37b02bb
Pin app theme injection to trusted origin
lawrencecchen Aug 1, 2026
bd2f958
Merge remote-tracking branch 'origin/main' into feat-annual-pricing
lawrencecchen Aug 1, 2026
7badff4
test: cover final annual pricing regressions
lawrencecchen Aug 1, 2026
95a977f
fix: close annual pricing review gaps
lawrencecchen Aug 1, 2026
689162d
test(web): exercise Stripe catalog behavior
lawrencecchen Aug 1, 2026
58187a7
test: cover cross-origin annual checkout handoff
lawrencecchen Aug 1, 2026
01b3a2e
fix: trust cross-origin checkout from app pricing
lawrencecchen Aug 1, 2026
2de43cc
test: reject arbitrary external pricing destinations
lawrencecchen Aug 1, 2026
6fff7b5
fix: pin external pricing handoff destination
lawrencecchen Aug 1, 2026
94fb05d
test: require same-origin app checkout relay
lawrencecchen Aug 1, 2026
bd3347e
fix: relay app checkout through trusted origin
lawrencecchen Aug 1, 2026
6313f5d
test: cover app origin and Stripe pagination guards
lawrencecchen Aug 1, 2026
86171a8
fix: close final pricing safety gaps
lawrencecchen Aug 1, 2026
16288c4
test: reject invalid pricing relay parameters
lawrencecchen Aug 1, 2026
b80286e
fix: validate app pricing relay parameters
lawrencecchen Aug 1, 2026
eb33602
test: cover app web loopback aliases
lawrencecchen Aug 1, 2026
65515f8
fix: share browser loopback host validation
lawrencecchen Aug 1, 2026
7eb3e31
refactor: keep app origin helpers scoped
lawrencecchen Aug 1, 2026
ac99420
test: keep unrelated Pagefind coverage unchanged
lawrencecchen Aug 1, 2026
d060ae5
test: preserve tagged callbacks through pricing relay
lawrencecchen Aug 1, 2026
27f2fd4
fix: preserve trusted tagged pricing callbacks
lawrencecchen Aug 1, 2026
7365941
fix: clean up embedded pricing layout
lawrencecchen Aug 1, 2026
29f60ce
test: cover annual Team labels without lookup keys
lawrencecchen Aug 1, 2026
880a08d
fix: label annual Team subscriptions from metadata
lawrencecchen Aug 1, 2026
465ba6c
test: cover annual pricing review regressions
lawrencecchen Aug 1, 2026
16e8936
fix: close annual pricing review gaps
lawrencecchen Aug 1, 2026
9c20eb6
Merge remote-tracking branch 'origin/main' into feat-annual-pricing
lawrencecchen Aug 1, 2026
b22f63e
Merge remote-tracking branch 'origin/main' into feat-annual-pricing
lawrencecchen Aug 1, 2026
9d263fb
fix: return validated restore arguments
lawrencecchen Aug 1, 2026
cff68a5
fix: align annual pricing interaction contracts
lawrencecchen Aug 1, 2026
535d274
fix: authenticate pricing callbacks and catalog retries
lawrencecchen Aug 1, 2026
c88e6dc
test: cover final annual pricing review regressions
lawrencecchen Aug 1, 2026
971518b
fix: close final annual pricing review gaps
lawrencecchen Aug 1, 2026
7cb2960
test: keep app theme policy in package suite
lawrencecchen Aug 1, 2026
ffcb8bc
Merge remote-tracking branch 'origin/main' into feat-annual-pricing
lawrencecchen Aug 1, 2026
3afa9eb
Record combined GhosttyKit artifact
lawrencecchen Aug 1, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
public import Foundation

/// Serializable app-web theme values shared by pricing and Pro welcome browser surfaces.
public struct BrowserAppTheme: Equatable, Sendable {
public let appearance: String
public let background: String
public let foreground: String
public let accent: String
public let accentOnBackground: String
public let accentOnForeground: String

/// Creates a complete theme after the app has resolved readable accent variants.
public init(
Comment on lines +12 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the new public initializer parameters

For package consumers, this six-argument public initializer leaves the meanings and invariants of the three accent variants undocumented. The package API rules require - Parameter documentation for public initializers and documentation for public property invariants, so add DocC entries for these arguments rather than only a summary sentence.

AGENTS.md reference: AGENTS.md:L99-L102

Useful? React with 👍 / 👎.

appearance: String,
background: String,
foreground: String,
accent: String,
accentOnBackground: String,
accentOnForeground: String
) {
self.appearance = appearance
self.background = background
self.foreground = foreground
self.accent = accent
self.accentOnBackground = accentOnBackground
self.accentOnForeground = accentOnForeground
}

/// Query items used to seed a server-rendered app-web page with the native theme.
public var queryItems: [URLQueryItem] {
[
URLQueryItem(name: "appearance", value: appearance),
URLQueryItem(name: "background", value: background),
URLQueryItem(name: "foreground", value: foreground),
URLQueryItem(name: "accent", value: accent),
URLQueryItem(name: "accent_on_background", value: accentOnBackground),
URLQueryItem(name: "accent_on_foreground", value: accentOnForeground),
]
}

/// JavaScript that refreshes an already-rendered supported page without navigation.
public func applyingJavaScript() -> String? {
let payload = JavaScriptPayload(
appearance: appearance,
background: background,
foreground: foreground,
accent: accent,
accentOnBackground: accentOnBackground,
accentOnForeground: accentOnForeground
)
guard let data = try? JSONEncoder().encode(payload),
let json = String(data: data, encoding: .utf8) else {
return nil
}
return """
(() => {
const root = document.querySelector('[data-cmux-app-theme]');
if (!root) return false;
const theme = \(json);
root.style.setProperty('--ghostty-background', theme.background);
root.style.setProperty('--ghostty-foreground', theme.foreground);
root.style.setProperty('--cmux-product-blue', theme.accent);
root.style.setProperty('--cmux-product-blue-on-background', theme.accentOnBackground);
root.style.setProperty('--cmux-product-blue-on-foreground', theme.accentOnForeground);
root.style.backgroundColor = theme.background;
root.style.colorScheme = theme.appearance;
root.dataset.cmuxAppThemeAppearance = theme.appearance;
if (root.hasAttribute('data-app-pricing-appearance')) {
root.setAttribute('data-app-pricing-appearance', theme.appearance);
}
if (root.hasAttribute('data-app-pro-welcome-appearance')) {
root.setAttribute('data-app-pro-welcome-appearance', theme.appearance);
}
for (const element of [document.documentElement, document.body]) {
element?.style.setProperty('background', theme.background, 'important');
}
document.querySelector('meta[name="theme-color"]')
?.setAttribute('content', theme.background);
return true;
})()
"""
}

/// Checks the app-web URL contract without constructing theme colors.
public static func supportsAppSurface(url: URL?, trustedOrigin: URL) -> Bool {
guard let url,
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https",
BrowserAppWebOrigin(trustedOrigin).containsAppSurface(url) else {
return false
}
return true
}

private struct JavaScriptPayload: Encodable {
let appearance: String
let background: String
let foreground: String
let accent: String
let accentOnBackground: String
let accentOnForeground: String
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,62 @@ struct BrowserAppWebOrigin {
}

func contains(_ candidate: URL) -> Bool {
guard candidate.scheme?.lowercased() == url.scheme?.lowercased(),
candidate.host?.lowercased() == url.host?.lowercased() else {
guard Self.isSecureOrLoopbackOrigin(url),
Self.isSecureOrLoopbackOrigin(candidate),
let scheme = url.scheme?.lowercased(),
let host = url.host?.lowercased(),
candidate.scheme?.lowercased() == scheme,
candidate.host?.lowercased() == host else {
return false
}
return effectivePort(candidate) == effectivePort(url)
return Self.effectivePort(candidate) == Self.effectivePort(url)
}

func containsAppSurface(_ candidate: URL) -> Bool {
guard contains(candidate) else { return false }
Comment thread
lawrencecchen marked this conversation as resolved.
let normalizedPath = candidate.path.count > 1
&& candidate.path.hasSuffix("/")
? String(candidate.path.dropLast())
: candidate.path
return normalizedPath == "/app-pricing"
|| normalizedPath == "/app-pro-welcome"
}

/// Recognizes local development hosts without accepting dotted-host lookalikes.
static func isLoopbackHost(_ candidate: String) -> Bool {
let host = candidate.lowercased()
if host == "localhost" || host == "::1" {
return true
}
if host != ".localhost", host.hasSuffix(".localhost") {
return true
}
let octets = host.split(separator: ".", omittingEmptySubsequences: false)
return octets.count == 4
&& octets.allSatisfy { UInt8($0) != nil }
&& octets[0] == "127"
}
}

private func effectivePort(_ url: URL) -> Int? {
if let port = url.port { return port }
switch url.scheme?.lowercased() {
case "http": return 80
case "https": return 443
default: return nil
private static func isSecureOrLoopbackOrigin(_ url: URL) -> Bool {
guard url.user == nil,
url.password == nil,
let host = url.host?.lowercased(),
!host.isEmpty else {
return false
}
if url.scheme?.lowercased() == "https" {
return true
}
guard url.scheme?.lowercased() == "http" else { return false }
return isLoopbackHost(host)
}

private static func effectivePort(_ url: URL) -> Int? {
if let port = url.port { return port }
switch url.scheme?.lowercased() {
case "http": return 80
case "https": return 443
default: return nil
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
public import Foundation

/// Decides whether a trusted app-web surface explicitly requests the system browser.
public struct BrowserExternalNavigationPolicy: Equatable, Sendable {
public let trustedOrigin: URL

/// Creates a policy pinned to the exact app-web origin.
public init(trustedOrigin: URL) {
self.trustedOrigin = trustedOrigin
}

/// Returns true only when a trusted app-web source activates a same-origin
/// web URL with an explicit intent marker.
public func shouldOpenInSystemBrowser(
_ url: URL,
sourceURL: URL?
) -> Bool {
guard let sourceURL,
BrowserAppWebOrigin(trustedOrigin).contains(sourceURL),
isSafeWebDestination(url),
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
components.queryItems?.contains(where: {
$0.name == "cmux_external_browser" && $0.value == "1"
}) == true else {
return false
}
return true
}

private func isSafeWebDestination(_ url: URL) -> Bool {
BrowserAppWebOrigin(trustedOrigin).contains(url)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,7 @@ public struct BrowserURLResolver: Sendable {

let lower = trimmed.lowercased()
let bareHost = bareHostCandidate(lower)
if bareHost == "localhost" ||
isIPv4Loopback(bareHost) ||
bareHost == "::1" ||
(bareHost != ".localhost" && bareHost.hasSuffix(".localhost")) {
if BrowserAppWebOrigin.isLoopbackHost(bareHost) {
return URL(string: "http://\(trimmed)")
}

Expand Down Expand Up @@ -174,13 +171,6 @@ public struct BrowserURLResolver: Sendable {
return String(lowercasedInput[..<end])
}

/// Recognizes IPv4 loopback addresses without accepting dotted-host lookalikes.
private func isIPv4Loopback(_ host: String) -> Bool {
let octets = host.split(separator: ".", omittingEmptySubsequences: false)
guard octets.count == 4, octets.allSatisfy({ UInt8($0) != nil }) else { return false }
return octets[0] == "127"
}

private func isDottedHostWithPort(_ input: String, schemeCandidate: String) -> Bool {
guard schemeCandidate.contains(".") else { return false }
guard input.count > schemeCandidate.count else { return false }
Expand Down
Loading