feat: switch OAuth redirect to localhost loopback (RFC 8252)
Feishu rejected the 'bugger://' custom URL scheme redirect with 参数不合法. Switched to the standard desktop OAuth flow: a one-shot local HTTP server on 127.0.0.1:18923 that receives the authorization code from the browser. Changes: - Added LocalOAuthServer (Network.framework, one-shot HTTP listener) - FeishuAuthService now uses dynamic http://127.0.0.1:PORT/callback redirect - Removed CFBundleURLTypes from Info.plist (no custom scheme needed) - Removed NSAppleEventManager handler from AppDelegate - TokenManager.handleCallback changed from URL to code string - OAuthSetupView starts server before opening browser - Added offline_access scope for refresh token support - generate_xcode_project.py: removed dev cert requirements, auto-quotes paths with special chars Builds successfully with xcodebuild. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
bda106dcad
commit
9f8fb52726
|
|
@ -25,6 +25,7 @@
|
||||||
C5D9B1FACE813CD43236BB77 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F99094B0F609809EBDB5F4A; };
|
C5D9B1FACE813CD43236BB77 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F99094B0F609809EBDB5F4A; };
|
||||||
29AE37B117A1422EA21570CA /* DateFormatter+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87C935E6B4D1813AB01186A0; };
|
29AE37B117A1422EA21570CA /* DateFormatter+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87C935E6B4D1813AB01186A0; };
|
||||||
64F1EE978536D1CB02B8F0D3 /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF06E1030AA21DA8190169A8; };
|
64F1EE978536D1CB02B8F0D3 /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF06E1030AA21DA8190169A8; };
|
||||||
|
EEC5853D00ED565270DC867F /* LocalOAuthServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D11523ACB81DBB9D412995; };
|
||||||
1FB7E762C0B6568C47B48EF0 /* URL+Feishu.swift in Sources */ = {isa = PBXBuildFile; fileRef = F888AA5F655343B19839B4C2; };
|
1FB7E762C0B6568C47B48EF0 /* URL+Feishu.swift in Sources */ = {isa = PBXBuildFile; fileRef = F888AA5F655343B19839B4C2; };
|
||||||
AECED74595B57A400437B9FD /* BugStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 344F52FB34B285D91A3E4670; };
|
AECED74595B57A400437B9FD /* BugStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 344F52FB34B285D91A3E4670; };
|
||||||
485A69E15EF1D805F50DF364 /* FloatingWidgetWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2FE32B404AB4388A066991; };
|
485A69E15EF1D805F50DF364 /* FloatingWidgetWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2FE32B404AB4388A066991; };
|
||||||
|
|
@ -55,6 +56,7 @@
|
||||||
0F99094B0F609809EBDB5F4A /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = "<group>"; };
|
0F99094B0F609809EBDB5F4A /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
87C935E6B4D1813AB01186A0 /* DateFormatter+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DateFormatter+Extensions.swift"; sourceTree = "<group>"; };
|
87C935E6B4D1813AB01186A0 /* DateFormatter+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DateFormatter+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
EF06E1030AA21DA8190169A8 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; };
|
EF06E1030AA21DA8190169A8 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; };
|
||||||
|
34D11523ACB81DBB9D412995 /* LocalOAuthServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalOAuthServer.swift; sourceTree = "<group>"; };
|
||||||
F888AA5F655343B19839B4C2 /* URL+Feishu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Feishu.swift"; sourceTree = "<group>"; };
|
F888AA5F655343B19839B4C2 /* URL+Feishu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Feishu.swift"; sourceTree = "<group>"; };
|
||||||
344F52FB34B285D91A3E4670 /* BugStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugStore.swift; sourceTree = "<group>"; };
|
344F52FB34B285D91A3E4670 /* BugStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugStore.swift; sourceTree = "<group>"; };
|
||||||
6E2FE32B404AB4388A066991 /* FloatingWidgetWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingWidgetWindow.swift; sourceTree = "<group>"; };
|
6E2FE32B404AB4388A066991 /* FloatingWidgetWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingWidgetWindow.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -131,6 +133,7 @@
|
||||||
0F99094B0F609809EBDB5F4A /* Color+Extensions.swift */,
|
0F99094B0F609809EBDB5F4A /* Color+Extensions.swift */,
|
||||||
87C935E6B4D1813AB01186A0 /* DateFormatter+Extensions.swift */,
|
87C935E6B4D1813AB01186A0 /* DateFormatter+Extensions.swift */,
|
||||||
EF06E1030AA21DA8190169A8 /* KeychainHelper.swift */,
|
EF06E1030AA21DA8190169A8 /* KeychainHelper.swift */,
|
||||||
|
34D11523ACB81DBB9D412995 /* LocalOAuthServer.swift */,
|
||||||
F888AA5F655343B19839B4C2 /* URL+Feishu.swift */,
|
F888AA5F655343B19839B4C2 /* URL+Feishu.swift */,
|
||||||
);
|
);
|
||||||
path = Utils;
|
path = Utils;
|
||||||
|
|
@ -304,6 +307,7 @@
|
||||||
C5D9B1FACE813CD43236BB77 /* Color+Extensions.swift in Sources */,
|
C5D9B1FACE813CD43236BB77 /* Color+Extensions.swift in Sources */,
|
||||||
29AE37B117A1422EA21570CA /* DateFormatter+Extensions.swift in Sources */,
|
29AE37B117A1422EA21570CA /* DateFormatter+Extensions.swift in Sources */,
|
||||||
64F1EE978536D1CB02B8F0D3 /* KeychainHelper.swift in Sources */,
|
64F1EE978536D1CB02B8F0D3 /* KeychainHelper.swift in Sources */,
|
||||||
|
EEC5853D00ED565270DC867F /* LocalOAuthServer.swift in Sources */,
|
||||||
1FB7E762C0B6568C47B48EF0 /* URL+Feishu.swift in Sources */,
|
1FB7E762C0B6568C47B48EF0 /* URL+Feishu.swift in Sources */,
|
||||||
AECED74595B57A400437B9FD /* BugStore.swift in Sources */,
|
AECED74595B57A400437B9FD /* BugStore.swift in Sources */,
|
||||||
485A69E15EF1D805F50DF364 /* FloatingWidgetWindow.swift in Sources */,
|
485A69E15EF1D805F50DF364 /* FloatingWidgetWindow.swift in Sources */,
|
||||||
|
|
@ -344,12 +348,8 @@
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = Resources/Bugger.entitlements;
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = "";
|
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
|
||||||
FEISHU_APP_ID = "$(FEISHU_APP_ID)";
|
FEISHU_APP_ID = "$(FEISHU_APP_ID)";
|
||||||
FEISHU_APP_SECRET = "$(FEISHU_APP_SECRET)";
|
FEISHU_APP_SECRET = "$(FEISHU_APP_SECRET)";
|
||||||
FEISHU_BASE_DOMAIN = "xorbitlab.feishu.cn";
|
FEISHU_BASE_DOMAIN = "xorbitlab.feishu.cn";
|
||||||
|
|
@ -369,12 +369,8 @@
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = Resources/Bugger.entitlements;
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = "";
|
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
|
||||||
FEISHU_APP_ID = "$(FEISHU_APP_ID)";
|
FEISHU_APP_ID = "$(FEISHU_APP_ID)";
|
||||||
FEISHU_APP_SECRET = "$(FEISHU_APP_SECRET)";
|
FEISHU_APP_SECRET = "$(FEISHU_APP_SECRET)";
|
||||||
FEISHU_BASE_DOMAIN = "xorbitlab.feishu.cn";
|
FEISHU_BASE_DOMAIN = "xorbitlab.feishu.cn";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict/>
|
||||||
|
</plist>
|
||||||
|
|
@ -22,17 +22,6 @@
|
||||||
<string>14.0</string>
|
<string>14.0</string>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>CFBundleURLTypes</key>
|
|
||||||
<array>
|
|
||||||
<dict>
|
|
||||||
<key>CFBundleURLName</key>
|
|
||||||
<string>com.xorbitlab.bugger</string>
|
|
||||||
<key>CFBundleURLSchemes</key>
|
|
||||||
<array>
|
|
||||||
<string>bugger</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</array>
|
|
||||||
<key>FEISHU_APP_ID</key>
|
<key>FEISHU_APP_ID</key>
|
||||||
<string>$(FEISHU_APP_ID)</string>
|
<string>$(FEISHU_APP_ID)</string>
|
||||||
<key>FEISHU_APP_SECRET</key>
|
<key>FEISHU_APP_SECRET</key>
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
Self.shared = self
|
Self.shared = self
|
||||||
NSApp.setActivationPolicy(.accessory)
|
NSApp.setActivationPolicy(.accessory)
|
||||||
|
|
||||||
NSAppleEventManager.shared().setEventHandler(
|
|
||||||
self,
|
|
||||||
andSelector: #selector(handleURLEvent(_:withReplyEvent:)),
|
|
||||||
forEventClass: AEEventClass(kInternetEventClass),
|
|
||||||
andEventID: AEEventID(kAEGetURL)
|
|
||||||
)
|
|
||||||
|
|
||||||
setupStatusItem()
|
setupStatusItem()
|
||||||
setupPopover()
|
setupPopover()
|
||||||
|
|
||||||
|
|
@ -89,11 +82,4 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
floatingWidget = nil
|
floatingWidget = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleURLEvent(_ event: NSAppleEventDescriptor, withReplyEvent: NSAppleEventDescriptor) {
|
|
||||||
guard let urlString = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue,
|
|
||||||
let url = URL(string: urlString) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
OAuthCallbackHandler.handle(url)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,14 @@ final class FeishuAuthService {
|
||||||
private let baseURL = "https://open.feishu.cn/open-apis"
|
private let baseURL = "https://open.feishu.cn/open-apis"
|
||||||
private let appId: String
|
private let appId: String
|
||||||
private let appSecret: String
|
private let appSecret: String
|
||||||
private let redirectURI = "bugger://oauth/callback"
|
|
||||||
private let session: URLSession
|
private let session: URLSession
|
||||||
private let decoder: JSONDecoder
|
private let decoder: JSONDecoder
|
||||||
|
|
||||||
|
/// The redirect URI used for the current OAuth flow. Set before calling
|
||||||
|
/// `authorizeURL` by the caller (OAuthSetupView) after starting the local
|
||||||
|
/// server so the port matches.
|
||||||
|
var redirectURI: String = "http://127.0.0.1:18923/callback"
|
||||||
|
|
||||||
private var cachedTenantToken: String?
|
private var cachedTenantToken: String?
|
||||||
private var tenantTokenExpiry: Date?
|
private var tenantTokenExpiry: Date?
|
||||||
|
|
||||||
|
|
@ -32,7 +36,7 @@ final class FeishuAuthService {
|
||||||
components.queryItems = [
|
components.queryItems = [
|
||||||
URLQueryItem(name: "app_id", value: appId),
|
URLQueryItem(name: "app_id", value: appId),
|
||||||
URLQueryItem(name: "redirect_uri", value: redirectURI),
|
URLQueryItem(name: "redirect_uri", value: redirectURI),
|
||||||
URLQueryItem(name: "scope", value: "bitable:app:readonly")
|
URLQueryItem(name: "scope", value: "bitable:app:readonly offline_access")
|
||||||
]
|
]
|
||||||
return components.url!
|
return components.url!
|
||||||
}
|
}
|
||||||
|
|
@ -46,8 +50,9 @@ final class FeishuAuthService {
|
||||||
request.setValue("Bearer \(tenantToken)", forHTTPHeaderField: "Authorization")
|
request.setValue("Bearer \(tenantToken)", forHTTPHeaderField: "Authorization")
|
||||||
request.httpBody = try JSONEncoder().encode([
|
request.httpBody = try JSONEncoder().encode([
|
||||||
"grant_type": "authorization_code",
|
"grant_type": "authorization_code",
|
||||||
"code": code
|
"code": code,
|
||||||
])
|
"redirect_uri": redirectURI,
|
||||||
|
] as [String: String])
|
||||||
return try await decodeAPIResponse(request)
|
return try await decodeAPIResponse(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ final class TokenManager {
|
||||||
static let shared = TokenManager()
|
static let shared = TokenManager()
|
||||||
|
|
||||||
private let keychain = KeychainHelper.shared
|
private let keychain = KeychainHelper.shared
|
||||||
private let authService = FeishuAuthService()
|
let authService = FeishuAuthService()
|
||||||
|
|
||||||
private let accessTokenKey = "feishu.access_token"
|
private let accessTokenKey = "feishu.access_token"
|
||||||
private let refreshTokenKey = "feishu.refresh_token"
|
private let refreshTokenKey = "feishu.refresh_token"
|
||||||
|
|
@ -86,8 +86,8 @@ final class TokenManager {
|
||||||
state = .error(message)
|
state = .error(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleCallback(url: URL) async throws {
|
func handleCallback(code: String) async throws {
|
||||||
guard let code = extractCode(from: url) else {
|
guard !code.isEmpty else {
|
||||||
throw TokenError.invalidCallback
|
throw TokenError.invalidCallback
|
||||||
}
|
}
|
||||||
state = .authenticating
|
state = .authenticating
|
||||||
|
|
@ -107,17 +107,6 @@ final class TokenManager {
|
||||||
return try await authService.fetchCurrentUserName(accessToken: accessToken)
|
return try await authService.fetchCurrentUserName(accessToken: accessToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func extractCode(from url: URL) -> String? {
|
|
||||||
guard url.scheme == "bugger",
|
|
||||||
url.host == "oauth",
|
|
||||||
url.path == "/callback" else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
|
||||||
.queryItems?
|
|
||||||
.first(where: { $0.name == "code" })?
|
|
||||||
.value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TokenError: Error, LocalizedError {
|
enum TokenError: Error, LocalizedError {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
/// Starts a one-shot HTTP server on localhost to receive the OAuth redirect.
|
||||||
|
/// Returns the authorization code extracted from the callback query string.
|
||||||
|
actor LocalOAuthServer {
|
||||||
|
private var listener: NWListener?
|
||||||
|
private var continuation: CheckedContinuation<String, Error>?
|
||||||
|
|
||||||
|
private let port: UInt16
|
||||||
|
|
||||||
|
init(port: UInt16 = 18_923) {
|
||||||
|
self.port = port
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full redirect URI that must be registered in the Feishu app console.
|
||||||
|
nonisolated var redirectURI: String {
|
||||||
|
"http://127.0.0.1:\(port)/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start listening and return the authorization code when the browser redirects.
|
||||||
|
func receiveCode() async throws -> String {
|
||||||
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
|
self.continuation = continuation
|
||||||
|
|
||||||
|
do {
|
||||||
|
let params = NWParameters.tcp
|
||||||
|
let listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!)
|
||||||
|
self.listener = listener
|
||||||
|
|
||||||
|
listener.newConnectionHandler = { [weak self] connection in
|
||||||
|
connection.start(queue: .global())
|
||||||
|
guard let self else { return }
|
||||||
|
Self.readConnection(connection) { code in
|
||||||
|
Task {
|
||||||
|
await self.didReceive(code: code, from: connection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listener.stateUpdateHandler = { state in
|
||||||
|
if case .failed(let error) = state {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listener.start(queue: .global())
|
||||||
|
} catch {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the server (called after receiving the code or on timeout).
|
||||||
|
func stop() {
|
||||||
|
listener?.cancel()
|
||||||
|
listener = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private (actor-isolated)
|
||||||
|
|
||||||
|
private func didReceive(code: String?, from connection: NWConnection) {
|
||||||
|
guard let code else {
|
||||||
|
Self.sendResponse(to: connection, status: 400, body: errorPage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Self.sendResponse(to: connection, status: 200, body: successPage)
|
||||||
|
continuation?.resume(returning: code)
|
||||||
|
continuation = nil
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Non-isolated helpers
|
||||||
|
|
||||||
|
/// Read the first chunk of an HTTP connection and extract the code.
|
||||||
|
private static func readConnection(
|
||||||
|
_ connection: NWConnection,
|
||||||
|
completion: @escaping (String?) -> Void
|
||||||
|
) {
|
||||||
|
connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { data, _, _, _ in
|
||||||
|
guard let data,
|
||||||
|
let request = String(data: data, encoding: .utf8) else {
|
||||||
|
completion(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completion(extractCode(from: request))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func extractCode(from request: String) -> String? {
|
||||||
|
// Parse "GET /callback?code=xxx HTTP/1.1"
|
||||||
|
guard let firstLine = request.components(separatedBy: "\r\n").first,
|
||||||
|
firstLine.hasPrefix("GET"),
|
||||||
|
let pathAndQuery = firstLine.components(separatedBy: " ").dropFirst().first,
|
||||||
|
pathAndQuery.hasPrefix("/callback"),
|
||||||
|
let queryStart = pathAndQuery.firstIndex(of: "?") else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = String(pathAndQuery[pathAndQuery.index(after: queryStart)...])
|
||||||
|
let params = query.components(separatedBy: "&")
|
||||||
|
for param in params {
|
||||||
|
let pair = param.components(separatedBy: "=")
|
||||||
|
if pair.first == "code", pair.count > 1 {
|
||||||
|
return pair[1].removingPercentEncoding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sendResponse(to connection: NWConnection, status: Int, body: String) {
|
||||||
|
let statusText = status == 200 ? "OK" : "Bad Request"
|
||||||
|
let response = """
|
||||||
|
HTTP/1.1 \(status) \(statusText)\r
|
||||||
|
Content-Type: text/html; charset=utf-8\r
|
||||||
|
Content-Length: \(body.utf8.count)\r
|
||||||
|
Connection: close\r
|
||||||
|
\r
|
||||||
|
\(body)
|
||||||
|
"""
|
||||||
|
connection.send(content: response.data(using: .utf8), completion: .contentProcessed { _ in
|
||||||
|
connection.cancel()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let successPage = """
|
||||||
|
<!DOCTYPE html><html><head><meta charset="utf-8"><title>Bugger</title>
|
||||||
|
<style>body{font-family:-apple-system,sans-serif;display:flex;justify-content:center;
|
||||||
|
align-items:center;height:100vh;margin:0;background:#f5f5f5}
|
||||||
|
.card{background:white;padding:40px;border-radius:12px;text-align:center;box-shadow:0
|
||||||
|
2px 12px rgba(0,0,0,0.1)}h1{color:#333} p{color:#666}</style></head>
|
||||||
|
<body><div class="card"><h1>Connected ✓</h1><p>Bugger has been authorized.
|
||||||
|
You can close this window.</p></div></body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
private let errorPage = """
|
||||||
|
<!DOCTYPE html><html><head><meta charset="utf-8"><title>Bugger</title></head>
|
||||||
|
<body><h1>Authorization Failed</h1><p>Could not extract the authorization code.
|
||||||
|
Please try again.</p></body></html>
|
||||||
|
"""
|
||||||
|
|
@ -53,22 +53,27 @@ struct OAuthSetupView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func connectFeishu() {
|
private func connectFeishu() {
|
||||||
guard let url = tokenManager.authorizeURL else { return }
|
Task {
|
||||||
statusMessage = "Complete authorization in your browser."
|
let server = LocalOAuthServer()
|
||||||
NSWorkspace.shared.open(url)
|
tokenManager.authService.redirectURI = server.redirectURI
|
||||||
}
|
|
||||||
|
guard let url = tokenManager.authorizeURL else {
|
||||||
|
statusMessage = "Missing credentials. Check FEISHU_APP_ID."
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
enum OAuthCallbackHandler {
|
statusMessage = "Complete authorization in your browser."
|
||||||
static func handle(_ url: URL) {
|
NSWorkspace.shared.open(url)
|
||||||
Task {
|
|
||||||
do {
|
do {
|
||||||
try await TokenManager.shared.handleCallback(url: url)
|
let code = try await server.receiveCode()
|
||||||
|
statusMessage = "Completing sign-in..."
|
||||||
|
try await tokenManager.handleCallback(code: code)
|
||||||
await PollerService.shared.startIfConfigured()
|
await PollerService.shared.startIfConfigured()
|
||||||
await PollerService.shared.fetchNow()
|
await PollerService.shared.fetchNow()
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
TokenManager.shared.setError(error.localizedDescription)
|
tokenManager.setError(error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -336,12 +336,8 @@ def build_pbxproj() -> str:
|
||||||
# Target Debug
|
# Target Debug
|
||||||
target_settings = [
|
target_settings = [
|
||||||
("ASSETCATALOG_COMPILER_APPICON_NAME", "AppIcon"),
|
("ASSETCATALOG_COMPILER_APPICON_NAME", "AppIcon"),
|
||||||
("CODE_SIGN_ENTITLEMENTS", "Resources/Bugger.entitlements"),
|
|
||||||
("CODE_SIGN_STYLE", "Automatic"),
|
|
||||||
("COMBINE_HIDPI_IMAGES", "YES"),
|
("COMBINE_HIDPI_IMAGES", "YES"),
|
||||||
("CURRENT_PROJECT_VERSION", "1"),
|
("CURRENT_PROJECT_VERSION", "1"),
|
||||||
("DEVELOPMENT_TEAM", '""'),
|
|
||||||
("ENABLE_HARDENED_RUNTIME", "YES"),
|
|
||||||
("FEISHU_APP_ID", '"$(FEISHU_APP_ID)"'),
|
("FEISHU_APP_ID", '"$(FEISHU_APP_ID)"'),
|
||||||
("FEISHU_APP_SECRET", '"$(FEISHU_APP_SECRET)"'),
|
("FEISHU_APP_SECRET", '"$(FEISHU_APP_SECRET)"'),
|
||||||
("FEISHU_BASE_DOMAIN", '"xorbitlab.feishu.cn"'),
|
("FEISHU_BASE_DOMAIN", '"xorbitlab.feishu.cn"'),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue