Merge nucleic/brisk-thistle-falcon-sazd into dev
Add a dedicated GitHub sign-in to Settings > Git, so connecting an account no longer requires hand-minting a personal access token. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -1599,13 +1599,19 @@ private struct MacVMSettingsTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Git tab: Nucleic's Managed Git credential and where it's used. The credential is always
|
||||
/// used for Nucleic's own host-side git (cloning a project, pushing a branch, opening a PR) and is
|
||||
/// optionally made available to sandboxed sessions. The SSH config section re-supplies any
|
||||
/// connectivity Managed Git's hermetic ssh would otherwise miss.
|
||||
/// The Git tab, in the order a user meets it: connect a GitHub account (one button, no secrets to
|
||||
/// mint), then the Managed Git credential and where it's used, then the SSH config that re-supplies
|
||||
/// any connectivity Managed Git's hermetic ssh would otherwise miss. The credential is always used
|
||||
/// for Nucleic's own host-side git (cloning a project, pushing a branch, opening a PR) and is
|
||||
/// optionally made available to sandboxed sessions.
|
||||
///
|
||||
/// GitHub leads deliberately: signing in is the answer for most people, and it configures Managed
|
||||
/// Git for them. Managed Git stays its own section for everyone who'd rather supply the exact
|
||||
/// credential themselves — a PAT, their own SSH key, or a Nucleic-minted one.
|
||||
private struct GitSettingsTab: View {
|
||||
var body: some View {
|
||||
Form {
|
||||
GitHubSignInSection()
|
||||
GitHubAccessSection()
|
||||
ManagedGitSSHConfigSection()
|
||||
}
|
||||
@@ -1613,6 +1619,204 @@ private struct GitSettingsTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Put a value on the general pasteboard. Shared by the Git sections — the sign-in code in one, the
|
||||
/// Nucleic-managed public key in the other.
|
||||
private func copyToPasteboard(_ value: String) {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(value, forType: .string)
|
||||
}
|
||||
|
||||
/// Connecting a GitHub account, as its own concern: one button, a browser round-trip, done. This is
|
||||
/// the front door — it exists so the common case never has to understand Managed Git's modes or hand-
|
||||
/// craft a personal access token. What it produces *is* an ordinary Managed Git token (same Keychain
|
||||
/// slot, same provisioning), so signing in here simply configures the section below.
|
||||
///
|
||||
/// Kept separate from `GitHubAccessSection` because the two answer different questions: this one is
|
||||
/// "which GitHub account is Nucleic acting as", that one is "which credential and transport does git
|
||||
/// use". A user who signs in should never need to open the second.
|
||||
private struct GitHubSignInSection: View {
|
||||
@AppStorage(GitHubCredentialSettings.authModeKey) private var modeRaw = GitHubAuthMode.none.rawValue
|
||||
/// The GitHub OAuth App client id browser sign-in runs against, when the user supplies their own.
|
||||
/// Public (the device flow has no secret), so it's an ordinary preference — see `GitHubOAuth`.
|
||||
@AppStorage(GitHubOAuth.clientIDKey) private var oauthClientID = ""
|
||||
|
||||
/// Where browser sign-in is: idle, asking GitHub for a code, or showing the code and polling.
|
||||
@State private var phase: Phase = .idle
|
||||
/// A failure from the sign-in flow, shown inline under the button.
|
||||
@State private var error: String?
|
||||
/// The in-flight sign-in, so Cancel can stop the poll loop.
|
||||
@State private var task: Task<Void, Never>?
|
||||
/// Whether the token Nucleic holds came from a sign-in here (rather than a pasted PAT). Backed by
|
||||
/// defaults rather than local state so `GitHubAccessSection` below — which renders the same
|
||||
/// Keychain slot — stays in step the instant this changes.
|
||||
@AppStorage(GitHubOAuth.signedInKey) private var signedIn = false
|
||||
|
||||
private enum Phase: Equatable {
|
||||
case idle
|
||||
/// Waiting on the device-code request (a single round-trip, so usually a blink).
|
||||
case requesting
|
||||
/// GitHub issued a code; showing it while polling for the user's approval.
|
||||
case awaiting(GitHubOAuth.DeviceCode)
|
||||
}
|
||||
|
||||
/// The OAuth App client id sign-in will use: the user's override, else whatever's baked into the
|
||||
/// build, else `nil` (sign-in unconfigured). Resolved through `@AppStorage` rather than
|
||||
/// `GitHubOAuth.clientID` so the UI updates live as they paste one in.
|
||||
private var resolvedClientID: String? {
|
||||
let override = oauthClientID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !override.isEmpty { return override }
|
||||
return GitHubOAuth.builtInClientID.isEmpty ? nil : GitHubOAuth.builtInClientID
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Section("GitHub") {
|
||||
if signedIn {
|
||||
signedInRow
|
||||
} else {
|
||||
signInControls
|
||||
}
|
||||
if let error {
|
||||
Label(error, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
// Only in the way when sign-in has nothing to run against.
|
||||
if resolvedClientID == nil { clientIDEntry }
|
||||
}
|
||||
// Reconcile the flag against the store: a token cleared from under it (an applied credential
|
||||
// bundle, a Keychain reset) must not leave this claiming an account that isn't there.
|
||||
.onAppear { signedIn = GitHubOAuth.isSignedIn }
|
||||
}
|
||||
|
||||
@ViewBuilder private var signedInRow: some View {
|
||||
HStack {
|
||||
Label("Signed in to GitHub", systemImage: "checkmark.seal.fill")
|
||||
.foregroundStyle(.green)
|
||||
Spacer()
|
||||
Button("Sign out", role: .destructive) { signOut() }
|
||||
}
|
||||
Text("Nucleic holds the token GitHub issued. It authenticates `git` over HTTPS while "
|
||||
+ "Authentication below is set to Token, and `gh`'s API calls in every mode. Signing out "
|
||||
+ "discards it.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
/// Browser sign-in via GitHub's device flow (``GitHubOAuth``): Nucleic shows the short user code,
|
||||
/// opens GitHub's approval page with it pre-filled, and polls until the user approves — then
|
||||
/// stores the issued token in the same Keychain slot a pasted PAT uses.
|
||||
@ViewBuilder private var signInControls: some View {
|
||||
switch phase {
|
||||
case .idle:
|
||||
Button { startSignIn() } label: {
|
||||
Label("Sign in with GitHub", systemImage: "person.badge.key.fill")
|
||||
}
|
||||
.disabled(resolvedClientID == nil)
|
||||
Text("Opens your browser to approve Nucleic, then stores the token GitHub issues — no "
|
||||
+ "personal access token to create by hand. Prefer to supply your own credential? "
|
||||
+ "Use Managed Git below.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
case .requesting:
|
||||
HStack(spacing: 8) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Contacting GitHub…").foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Cancel") { cancelSignIn() }
|
||||
}
|
||||
case .awaiting(let device):
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Approve the sign-in in your browser. Nucleic opened GitHub with this code "
|
||||
+ "filled in, and copied it to your clipboard in case you need to paste it:")
|
||||
HStack(spacing: 8) {
|
||||
Text(device.userCode)
|
||||
.font(.system(.title3, design: .monospaced).weight(.semibold))
|
||||
.textSelection(.enabled)
|
||||
Button("Copy") { copyToPasteboard(device.userCode) }
|
||||
Button("Open GitHub") { openVerificationPage(device) }
|
||||
Spacer()
|
||||
ProgressView().controlSize(.small)
|
||||
Button("Cancel") { cancelSignIn() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GitHub publishes no shared client id the way Anthropic and OpenAI do for their CLIs, so
|
||||
/// sign-in needs an OAuth App. A client id is public (the device flow carries no secret), which
|
||||
/// is why this is a plain text field rather than a Keychain-backed one.
|
||||
@ViewBuilder private var clientIDEntry: some View {
|
||||
LabeledContent("OAuth App client ID") {
|
||||
TextField("Ov23li…", text: $oauthClientID)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 220)
|
||||
}
|
||||
Text("Sign-in uses GitHub's device flow. Register an OAuth App at "
|
||||
+ "github.com/settings/developers with “Enable Device Flow” turned on, then paste its "
|
||||
+ "client ID here.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private func startSignIn() {
|
||||
guard let clientID = resolvedClientID else { return }
|
||||
error = nil
|
||||
phase = .requesting
|
||||
task?.cancel()
|
||||
task = Task {
|
||||
let oauth = GitHubOAuth(clientID: clientID)
|
||||
do {
|
||||
let device = try await oauth.requestDeviceCode()
|
||||
phase = .awaiting(device)
|
||||
// Put the code on the clipboard before opening the page: GitHub pre-fills it from the
|
||||
// link, but if anything drops the parameter the user's next instinct — paste — works
|
||||
// without them having to reach back to this window first.
|
||||
copyToPasteboard(device.userCode)
|
||||
openVerificationPage(device)
|
||||
finishSignIn(token: try await oauth.pollForToken(device))
|
||||
} catch is CancellationError {
|
||||
phase = .idle
|
||||
} catch {
|
||||
phase = .idle
|
||||
let detail = (error as? GitHubOAuth.OAuthError)?.description
|
||||
?? error.localizedDescription
|
||||
self.error = "Sign-in failed: \(detail)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Store the freshly issued token. A user who signed in while no credential was configured
|
||||
/// clearly wants Nucleic talking to GitHub, so select token mode for them; a user already on an
|
||||
/// SSH or Nucleic-managed key keeps that transport and just gains the API token `gh` wants.
|
||||
private func finishSignIn(token: String) {
|
||||
GitHubCredentialStore.saveToken(token)
|
||||
if GitHubAuthMode(rawValue: modeRaw) ?? .none == .none {
|
||||
modeRaw = GitHubAuthMode.token.rawValue
|
||||
}
|
||||
phase = .idle
|
||||
error = nil
|
||||
signedIn = true
|
||||
}
|
||||
|
||||
private func signOut() {
|
||||
cancelSignIn()
|
||||
GitHubCredentialStore.deleteToken()
|
||||
signedIn = false
|
||||
error = nil
|
||||
}
|
||||
|
||||
private func cancelSignIn() {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
phase = .idle
|
||||
}
|
||||
|
||||
private func openVerificationPage(_ device: GitHubOAuth.DeviceCode) {
|
||||
guard let url = device.verificationURL else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extra OpenSSH config for Managed Git's connections to GitHub. Managed Git deliberately ignores
|
||||
/// the user's real `~/.ssh/config` (to stay off their personal keys and ssh-agent), which also drops
|
||||
/// any connectivity directives there — so this section lets the user re-supply just the connectivity
|
||||
@@ -1652,9 +1856,10 @@ private struct GitHubAccessSection: View {
|
||||
/// commit against the account whose signing key the managed key is).
|
||||
@AppStorage(GitHubCredentialSettings.managedCommitNameKey) private var commitName = ""
|
||||
@AppStorage(GitHubCredentialSettings.managedCommitEmailKey) private var commitEmail = ""
|
||||
/// The GitHub OAuth App client id browser sign-in runs against, when the user supplies their own.
|
||||
/// Public (the device flow has no secret), so it's an ordinary preference — see `GitHubOAuth`.
|
||||
@AppStorage(GitHubOAuth.clientIDKey) private var oauthClientID = ""
|
||||
/// Whether the stored token came from the GitHub section's sign-in. Shared with that section
|
||||
/// through defaults, so this one shows "Signed in to GitHub" instead of offering to clear a
|
||||
/// credential the user never pasted.
|
||||
@AppStorage(GitHubOAuth.signedInKey) private var signedIn = false
|
||||
|
||||
/// Draft input for the secret being entered; never pre-filled from the Keychain (we don't
|
||||
/// read secrets back into the UI). `tokenStored` / `keyStored` reflect whether one is saved.
|
||||
@@ -1675,32 +1880,8 @@ private struct GitHubAccessSection: View {
|
||||
/// A failure from generating the managed key.
|
||||
@State private var managedError: String?
|
||||
|
||||
/// Where browser sign-in is: idle, asking GitHub for a code, or showing the code and polling.
|
||||
@State private var signInPhase: SignInPhase = .idle
|
||||
/// A failure from the sign-in flow, shown inline under the button.
|
||||
@State private var signInError: String?
|
||||
/// The in-flight sign-in, so Cancel can stop the poll loop.
|
||||
@State private var signInTask: Task<Void, Never>?
|
||||
|
||||
private enum SignInPhase: Equatable {
|
||||
case idle
|
||||
/// Waiting on the device-code request (a single round-trip, so usually a blink).
|
||||
case requesting
|
||||
/// GitHub issued a code; showing it while polling for the user's approval.
|
||||
case awaiting(GitHubOAuth.DeviceCode)
|
||||
}
|
||||
|
||||
private var mode: GitHubAuthMode { GitHubAuthMode(rawValue: modeRaw) ?? .none }
|
||||
|
||||
/// The OAuth App client id sign-in will use: the user's override, else whatever's baked into the
|
||||
/// build, else `nil` (sign-in unconfigured). Resolved through `@AppStorage` rather than
|
||||
/// `GitHubOAuth.clientID` so the UI updates live as they paste one in.
|
||||
private var resolvedClientID: String? {
|
||||
let override = oauthClientID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !override.isEmpty { return override }
|
||||
return GitHubOAuth.builtInClientID.isEmpty ? nil : GitHubOAuth.builtInClientID
|
||||
}
|
||||
|
||||
/// True wherever Nucleic Control runs — which is everywhere the container service exists, since
|
||||
/// Control is on for every project. Control sessions run in the shared container, which has no
|
||||
/// other way to reach GitHub, so the credential is always made available to them; the toggle is
|
||||
@@ -1718,7 +1899,11 @@ private struct GitHubAccessSection: View {
|
||||
|
||||
switch mode {
|
||||
case .none:
|
||||
noCredentialSignIn
|
||||
// Nothing to configure — the GitHub section above is where a user without a
|
||||
// credential is meant to start.
|
||||
Text("Nucleic reaches only public repositories until a credential is configured.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
case .token:
|
||||
tokenControls
|
||||
case .ssh:
|
||||
@@ -1749,123 +1934,6 @@ private struct GitHubAccessSection: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Sign in with GitHub
|
||||
|
||||
/// What `.none` mode offers: the one-click path, so a user who just wants Nucleic to reach their
|
||||
/// private repos never has to learn what a PAT is. Signing in from here also selects token mode
|
||||
/// (see `finishSignIn`); the pickers above stay for anyone who'd rather supply their own secret.
|
||||
@ViewBuilder private var noCredentialSignIn: some View {
|
||||
gitHubSignIn
|
||||
Text("Without a credential Nucleic reaches only public repositories. Signing in with GitHub "
|
||||
+ "sets up token authentication for you — or pick a mode above to supply a token or SSH "
|
||||
+ "key yourself.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
/// Browser sign-in via GitHub's device flow (``GitHubOAuth``): Nucleic shows the short user code,
|
||||
/// opens GitHub's approval page, and polls until the user approves — then stores the issued token
|
||||
/// in the same Keychain slot a pasted PAT uses, so nothing downstream can tell the difference.
|
||||
@ViewBuilder private var gitHubSignIn: some View {
|
||||
switch signInPhase {
|
||||
case .idle:
|
||||
Button { startSignIn() } label: {
|
||||
Label("Sign in with GitHub", systemImage: "person.badge.key.fill")
|
||||
}
|
||||
.disabled(resolvedClientID == nil)
|
||||
case .requesting:
|
||||
HStack(spacing: 8) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Contacting GitHub…").foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Cancel") { cancelSignIn() }
|
||||
}
|
||||
case .awaiting(let device):
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Enter this code on GitHub to finish signing in:")
|
||||
HStack(spacing: 8) {
|
||||
Text(device.userCode)
|
||||
.font(.system(.title3, design: .monospaced).weight(.semibold))
|
||||
.textSelection(.enabled)
|
||||
Button("Copy") { copyToPasteboard(device.userCode) }
|
||||
Button("Open GitHub") { openVerificationPage(device) }
|
||||
Spacer()
|
||||
ProgressView().controlSize(.small)
|
||||
Button("Cancel") { cancelSignIn() }
|
||||
}
|
||||
}
|
||||
}
|
||||
if let signInError {
|
||||
Label(signInError, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
// Only in the way when sign-in has nothing to run against.
|
||||
if resolvedClientID == nil { clientIDEntry }
|
||||
}
|
||||
|
||||
/// GitHub publishes no shared client id the way Anthropic and OpenAI do for their CLIs, so
|
||||
/// sign-in needs an OAuth App. A client id is public (the device flow carries no secret), which
|
||||
/// is why this is a plain text field rather than a Keychain-backed one.
|
||||
@ViewBuilder private var clientIDEntry: some View {
|
||||
LabeledContent("OAuth App client ID") {
|
||||
TextField("Ov23li…", text: $oauthClientID)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 220)
|
||||
}
|
||||
Text("Sign-in uses GitHub's device flow. Register an OAuth App at "
|
||||
+ "github.com/settings/developers with “Enable Device Flow” turned on, then paste its "
|
||||
+ "client ID here.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private func startSignIn() {
|
||||
guard let clientID = resolvedClientID else { return }
|
||||
signInError = nil
|
||||
signInPhase = .requesting
|
||||
signInTask?.cancel()
|
||||
signInTask = Task {
|
||||
let oauth = GitHubOAuth(clientID: clientID)
|
||||
do {
|
||||
let device = try await oauth.requestDeviceCode()
|
||||
signInPhase = .awaiting(device)
|
||||
openVerificationPage(device)
|
||||
finishSignIn(token: try await oauth.pollForToken(device))
|
||||
} catch is CancellationError {
|
||||
signInPhase = .idle
|
||||
} catch {
|
||||
signInPhase = .idle
|
||||
let detail = (error as? GitHubOAuth.OAuthError)?.description
|
||||
?? error.localizedDescription
|
||||
signInError = "Sign-in failed: \(detail)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Store the freshly issued token. A user who signed in from `.none` clearly wants Nucleic
|
||||
/// talking to GitHub, so select token mode for them; a user already on an SSH or Nucleic-managed
|
||||
/// key keeps that transport and just gains the API token `gh` wants (the same slot the SSH modes'
|
||||
/// optional token field fills).
|
||||
private func finishSignIn(token: String) {
|
||||
GitHubCredentialStore.saveToken(token)
|
||||
if mode == .none { modeRaw = GitHubAuthMode.token.rawValue }
|
||||
signInPhase = .idle
|
||||
signInError = nil
|
||||
refreshStoredFlags()
|
||||
}
|
||||
|
||||
private func cancelSignIn() {
|
||||
signInTask?.cancel()
|
||||
signInTask = nil
|
||||
signInPhase = .idle
|
||||
}
|
||||
|
||||
private func openVerificationPage(_ device: GitHubOAuth.DeviceCode) {
|
||||
guard let url = device.verificationURL else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
// MARK: Token
|
||||
|
||||
@ViewBuilder private var tokenControls: some View {
|
||||
@@ -1873,22 +1941,28 @@ private struct GitHubAccessSection: View {
|
||||
}
|
||||
|
||||
/// Save/clear UI for the stored PAT. Backed by the one Keychain token slot, so the same secret
|
||||
/// serves token mode (git + gh) and SSH mode (gh API) — and is where browser sign-in deposits
|
||||
/// the token it obtains, which is why signing in is offered right alongside pasting one.
|
||||
/// serves token mode (git + gh) and SSH mode (gh API) — and it's the slot the GitHub section's
|
||||
/// sign-in fills, so this reads "Signed in to GitHub" when that's where the token came from
|
||||
/// rather than offering to clear a credential the user never pasted.
|
||||
@ViewBuilder private func tokenEntry() -> some View {
|
||||
if tokenStored {
|
||||
if signedIn {
|
||||
Label("Signed in to GitHub", systemImage: "checkmark.seal.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text("Using the token from the GitHub section above. Sign out there to replace it.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if tokenStored {
|
||||
storedRow(label: "Token saved") {
|
||||
GitHubCredentialStore.deleteToken()
|
||||
refreshStoredFlags()
|
||||
}
|
||||
} else {
|
||||
gitHubSignIn
|
||||
Text("or paste a personal access token")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
SecureField("Personal access token", text: $tokenDraft)
|
||||
Button("Save token") {
|
||||
GitHubCredentialStore.saveToken(tokenDraft.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
// A hand-pasted token supersedes whatever sign-in left behind, so the GitHub section
|
||||
// must stop claiming an account connection for it.
|
||||
signedIn = false
|
||||
tokenDraft = ""
|
||||
refreshStoredFlags()
|
||||
}
|
||||
@@ -2060,11 +2134,6 @@ private struct GitHubAccessSection: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func copyToPasteboard(_ value: String) {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(value, forType: .string)
|
||||
}
|
||||
|
||||
// MARK: Shared SSH options (apply to both .ssh and .managed)
|
||||
|
||||
@ViewBuilder private var sshOptions: some View {
|
||||
|
||||
@@ -47,6 +47,23 @@ public struct GitHubOAuth: Sendable {
|
||||
return resolved.isEmpty ? nil : resolved
|
||||
}
|
||||
|
||||
/// Whether the token in ``GitHubCredentialStore`` came from a browser sign-in rather than being
|
||||
/// pasted in by hand. Both land in the same Keychain slot and behave identically — this only
|
||||
/// lets Settings say "Signed in to GitHub" honestly instead of claiming an account connection
|
||||
/// for a PAT the user minted themselves. Deliberately *not* part of ``GitCredentialBundle``: a
|
||||
/// runner never signs in interactively, so forwarding it would only churn the mesh payload.
|
||||
public static let signedInKey = "nucleic.github.signedInViaOAuth"
|
||||
|
||||
/// True when a browser sign-in issued the token Nucleic currently holds. The flag alone isn't
|
||||
/// trusted — this re-checks the store, so a token cleared out from under it (an applied
|
||||
/// credential bundle, a Keychain reset) reads as signed out rather than leaving Settings
|
||||
/// asserting an account connection that no longer exists. Settings reconciles the stored flag
|
||||
/// against this on appear.
|
||||
public static var isSignedIn: Bool {
|
||||
UserDefaults.standard.bool(forKey: signedInKey)
|
||||
&& !(GitHubCredentialStore.loadToken() ?? "").isEmpty
|
||||
}
|
||||
|
||||
/// Where Nucleic requests the device/user code pair.
|
||||
public static let deviceCodeEndpoint = "https://github.com/login/device/code"
|
||||
/// Where Nucleic polls for the access token once the user has approved.
|
||||
@@ -141,8 +158,22 @@ public struct GitHubOAuth: Sendable {
|
||||
self.interval = interval
|
||||
}
|
||||
|
||||
/// The verification page as a `URL`, for opening in the browser.
|
||||
public var verificationURL: URL? { URL(string: verificationURI) }
|
||||
/// The verification page to open, with the user code appended as a `user_code` query
|
||||
/// parameter so GitHub can pre-fill it and the user doesn't have to retype it.
|
||||
///
|
||||
/// GitHub does **not** return RFC 8628's `verification_uri_complete` (verified against the
|
||||
/// live endpoint: the response carries only the five fields above), so Nucleic composes the
|
||||
/// link itself. GitHub carries the parameter through its own sign-in redirect — a logged-out
|
||||
/// user gets bounced to `/login?return_to=…user_code%3D…` and lands back here with it intact —
|
||||
/// so it survives the worst case of the user not being signed in yet. Should GitHub ever
|
||||
/// ignore the parameter, the page just renders its empty code box: exactly what the user
|
||||
/// would have seen without it, with the code still on screen and on their clipboard.
|
||||
public var verificationURL: URL? {
|
||||
guard var components = URLComponents(string: verificationURI) else { return nil }
|
||||
components.queryItems = (components.queryItems ?? [])
|
||||
+ [URLQueryItem(name: "user_code", value: userCode)]
|
||||
return components.url
|
||||
}
|
||||
}
|
||||
|
||||
/// Step 1: ask GitHub for a device/user code pair. The caller then shows `userCode` and opens
|
||||
|
||||
@@ -57,7 +57,27 @@ struct GitHubOAuthTests {
|
||||
#expect(device.verificationURI == "https://github.com/login/device")
|
||||
#expect(device.expiresIn == 899)
|
||||
#expect(device.interval == 5)
|
||||
#expect(device.verificationURL?.absoluteString == "https://github.com/login/device")
|
||||
}
|
||||
|
||||
/// GitHub returns no `verification_uri_complete`, so Nucleic appends the user code itself — the
|
||||
/// difference between the user retyping `WDJB-MJHT` and the page arriving with it filled in.
|
||||
@Test func verificationURLCarriesTheUserCode() throws {
|
||||
let device = try #require(GitHubOAuth.deviceCode(from: Data(Self.deviceCodeBody.utf8)))
|
||||
#expect(
|
||||
device.verificationURL?.absoluteString
|
||||
== "https://github.com/login/device?user_code=WDJB-MJHT")
|
||||
}
|
||||
|
||||
/// A `verification_uri` that already has a query keeps it — the code is appended, not swapped in.
|
||||
@Test func verificationURLPreservesAnExistingQuery() throws {
|
||||
let body = """
|
||||
{"device_code":"dc","user_code":"AAAA-BBBB",\
|
||||
"verification_uri":"https://github.example.com/login/device?flow=x"}
|
||||
"""
|
||||
let device = try #require(GitHubOAuth.deviceCode(from: Data(body.utf8)))
|
||||
#expect(
|
||||
device.verificationURL?.absoluteString
|
||||
== "https://github.example.com/login/device?flow=x&user_code=AAAA-BBBB")
|
||||
}
|
||||
|
||||
@Test func deviceCodeDefaultsIntervalAndVerificationURI() throws {
|
||||
|
||||
Reference in New Issue
Block a user