cedarstalking with keyboard shortcuts
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

bug: fix auth handler to use proper tmp dir

+144 -119
assets/auth-browser-bin

This is a binary file and will not be displayed.

+71 -27
assets/auth-browser.swift
··· 1 1 import Cocoa 2 2 import WebKit 3 + import Foundation 3 4 4 5 let TARGET_HOST = "selfservice.cedarville.edu" 5 6 let AUTH_COOKIES: Set<String> = [".ASPXAUTH", "studentselfservice_live"] 7 + let CONFIG_FILE = "/tmp/cedarstalk-auth-config.json" 8 + 9 + struct Config: Decodable { 10 + let signInUrl: String 11 + let cookieFile: String 12 + let logFile: String 13 + } 14 + 15 + func readConfig() -> (Config, URL)? { 16 + guard let configData = try? Data(contentsOf: URL(fileURLWithPath: CONFIG_FILE)), 17 + let config = try? JSONDecoder().decode(Config.self, from: configData), 18 + let signInURL = URL(string: config.signInUrl) 19 + else { return nil } 20 + return (config, signInURL) 21 + } 22 + 23 + class CookieObserver: NSObject, WKHTTPCookieStoreObserver { 24 + weak var browser: AuthBrowser? 25 + func cookiesDidChange(in cookieStore: WKHTTPCookieStore) { 26 + browser?.checkCookies(in: cookieStore) 27 + } 28 + } 6 29 7 30 class AuthBrowser: NSObject, NSApplicationDelegate, WKNavigationDelegate, NSWindowDelegate { 31 + let signInURL: URL 32 + let config: Config 8 33 var window: NSWindow! 9 34 var webView: WKWebView! 10 - let signInUrl: URL 11 - let cookieOutputFile: String 35 + var cookieObserver: CookieObserver! 12 36 var didComplete = false 13 37 14 - init(url: URL, cookieOutputFile: String) { 15 - self.signInUrl = url 16 - self.cookieOutputFile = cookieOutputFile 38 + init(url: URL, config: Config) { 39 + self.signInURL = url 40 + self.config = config 41 + super.init() 42 + } 43 + 44 + func log(_ msg: String) { 45 + let line = "[auth-browser] \(msg)\n" 46 + fputs(line, stderr) 47 + if let data = line.data(using: .utf8) { 48 + if let fh = FileHandle(forWritingAtPath: config.logFile) { 49 + fh.seekToEndOfFile(); fh.write(data); fh.closeFile() 50 + } else { 51 + try? data.write(to: URL(fileURLWithPath: config.logFile)) 52 + } 53 + } 17 54 } 18 55 19 56 func applicationDidFinishLaunching(_: Notification) { 20 - let config = WKWebViewConfiguration() 21 - config.websiteDataStore = .nonPersistent() // fully isolated, no shared cookies 57 + let wkConfig = WKWebViewConfiguration() 58 + wkConfig.websiteDataStore = .nonPersistent() 59 + 60 + cookieObserver = CookieObserver() 61 + cookieObserver.browser = self 62 + wkConfig.websiteDataStore.httpCookieStore.add(cookieObserver) 22 63 23 64 let rect = NSRect(x: 0, y: 0, width: 520, height: 700) 24 - webView = WKWebView(frame: rect, configuration: config) 65 + webView = WKWebView(frame: rect, configuration: wkConfig) 25 66 webView.navigationDelegate = self 26 67 webView.autoresizingMask = [.width, .height] 27 68 ··· 38 79 window.makeKeyAndOrderFront(nil) 39 80 NSApp.activate(ignoringOtherApps: true) 40 81 41 - webView.load(URLRequest(url: signInUrl)) 82 + log("loading \(signInURL.absoluteString)") 83 + webView.load(URLRequest(url: signInURL)) 42 84 } 43 85 44 86 func webView(_ webView: WKWebView, didFinish _: WKNavigation!) { 45 - guard !didComplete, 46 - let host = webView.url?.host, 47 - host.contains(TARGET_HOST) 48 - else { return } 87 + log("didFinish: \(webView.url?.absoluteString ?? "nil")") 88 + checkCookies(in: webView.configuration.websiteDataStore.httpCookieStore) 89 + } 49 90 50 - webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { [weak self] all in 91 + func checkCookies(in cookieStore: WKHTTPCookieStore) { 92 + guard !didComplete else { return } 93 + cookieStore.getAllCookies { [weak self] all in 51 94 guard let self, !self.didComplete else { return } 95 + if !all.isEmpty { 96 + let summary = all.map { "\($0.domain)/\($0.name)" }.joined(separator: ", ") 97 + self.log("cookies (\(all.count)): \(summary)") 98 + } 52 99 let site = all.filter { $0.domain.contains(TARGET_HOST) } 53 100 guard site.contains(where: { AUTH_COOKIES.contains($0.name) }) else { return } 54 101 55 102 self.didComplete = true 56 103 let cookieStr = site.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") 57 - try? cookieStr.write(toFile: self.cookieOutputFile, atomically: true, encoding: .utf8) 104 + try? cookieStr.write(toFile: self.config.cookieFile, atomically: true, encoding: .utf8) 105 + self.log("auth complete, wrote cookie") 58 106 NSApp.terminate(nil) 59 107 } 60 108 } ··· 62 110 func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { true } 63 111 64 112 func windowWillClose(_: Notification) { 65 - if !didComplete { exit(1) } 113 + if !didComplete { 114 + log("window closed without completing auth") 115 + exit(1) 116 + } 66 117 } 67 118 } 68 119 69 - // Filter out ALL system-injected flags (-psn_XXX, -AppleLanguages, etc.) 70 - // Our args are positional: URL (starts with https) and file path (starts with /) 71 - let args = CommandLine.arguments.dropFirst().filter { !$0.hasPrefix("-") } 72 - 73 - guard args.count >= 2, 74 - let url = URL(string: String(args[0])), 75 - url.scheme != nil 76 - else { 77 - fputs("Usage: auth-browser <url> <cookie-output-file>\n", stderr) 120 + guard let (config, signInURL) = readConfig() else { 121 + fputs("auth-browser: failed to read config from \(CONFIG_FILE)\n", stderr) 78 122 exit(1) 79 123 } 80 124 81 - let cookieOutputFile = String(args[1]) 125 + try? "".write(toFile: config.logFile, atomically: true, encoding: .utf8) 82 126 83 127 let app = NSApplication.shared 84 - let delegate = AuthBrowser(url: url, cookieOutputFile: cookieOutputFile) 128 + let delegate = AuthBrowser(url: signInURL, config: config) 85 129 app.setActivationPolicy(.regular) 86 130 app.delegate = delegate 87 131 app.run()
+48 -55
src/auth.ts
··· 1 1 import { environment, LocalStorage } from "@raycast/api"; 2 2 import { exec, spawn } from "child_process"; 3 - import { 4 - access, 5 - mkdir, 6 - readFile, 7 - symlink, 8 - unlink, 9 - writeFile, 10 - } from "fs/promises"; 3 + import { access, mkdir, readFile, symlink, unlink, writeFile } from "fs/promises"; 11 4 import * as os from "os"; 12 5 import * as path from "path"; 13 6 import { promisify } from "util"; ··· 15 8 const execAsync = promisify(exec); 16 9 const COOKIE_KEY = "session_cookie"; 17 10 11 + // Use /tmp directly (not os.tmpdir) so paths match the Swift binary's hardcoded CONFIG_FILE. 12 + // os.tmpdir() under Raycast returns /var/folders/…/T which differs from /tmp. 13 + const CONFIG_FILE = "/tmp/cedarstalk-auth-config.json"; 14 + const COOKIE_FILE = "/tmp/cedarstalk-cookie.txt"; 15 + const LOG_FILE = "/tmp/cedarstalk-auth.log"; 16 + 18 17 // ─── Cookie storage ──────────────────────────────────────────────────────── 19 18 20 19 export async function getStoredCookie(): Promise<string | undefined> { ··· 29 28 await LocalStorage.removeItem(COOKIE_KEY); 30 29 } 31 30 31 + // If Raycast closed mid-auth but the Swift app finished and wrote the cookie, 32 + // pick it up on the next open and delete it from disk. 33 + export async function drainPendingCookie(): Promise<string | undefined> { 34 + try { 35 + const cookie = (await readFile(COOKIE_FILE, "utf-8")).trim(); 36 + await unlink(COOKIE_FILE).catch(() => {}); 37 + if (cookie) return cookie; 38 + } catch { 39 + // file doesn't exist — nothing pending 40 + } 41 + } 42 + 32 43 // ─── Auth browser ────────────────────────────────────────────────────────── 33 44 34 45 // Opens an isolated WKWebView window via a temporary .app bundle so macOS 35 - // grants it proper window-server access. Cookie is returned through a temp file. 46 + // grants it proper window-server access. Config is passed via a JSON file 47 + // (not --args) to avoid Launch Services caching stale arguments. 36 48 export async function launchAuthBrowser(signInUrl: string): Promise<string> { 37 49 const binaryPath = await ensureBinary(); 38 - const sessionId = Date.now().toString(36); 39 - const appBundle = await ensureAppBundle(binaryPath, sessionId); 40 - const cookieFile = path.join(os.tmpdir(), `cedarstalk-cookie-${sessionId}.txt`); 50 + const appBundle = await ensureAppBundle(binaryPath); 41 51 42 - await unlink(cookieFile).catch(() => {}); 52 + // Write config before launch — Swift reads this instead of using --args 53 + await unlink(COOKIE_FILE).catch(() => {}); 54 + await unlink(LOG_FILE).catch(() => {}); 55 + await writeFile(CONFIG_FILE, JSON.stringify({ signInUrl, cookieFile: COOKIE_FILE, logFile: LOG_FILE })); 43 56 44 - // Use spawn (not execAsync) so the SAML URL isn't shell-interpreted 45 57 await new Promise<void>((resolve, reject) => { 46 - const proc = spawn( 47 - "open", 48 - ["-n", "-W", appBundle, "--args", signInUrl, cookieFile], 49 - { 50 - stdio: "ignore", 51 - }, 52 - ); 53 - proc.on("close", (code) => { 54 - // open -W exits 0 whether the app succeeded or cancelled; 55 - // we detect success by whether the cookie file was written 56 - resolve(); 57 - }); 58 + const proc = spawn("open", ["-n", "-W", appBundle], { stdio: "ignore" }); 59 + proc.on("close", () => resolve()); 58 60 proc.on("error", reject); 59 61 }); 60 62 61 - const cookie = await readFile(cookieFile, "utf-8") 62 - .then((s) => s.trim()) 63 - .catch(() => ""); 64 - await unlink(cookieFile).catch(() => {}); 65 - // Clean up the ephemeral app bundle 66 - await execAsync(`rm -rf "${appBundle}"`).catch(() => {}); 63 + const log = await readFile(LOG_FILE, "utf-8").catch(() => "(no log)"); 64 + await unlink(LOG_FILE).catch(() => {}); 67 65 68 - if (!cookie) throw new Error("Sign-in cancelled"); 66 + const cookie = await readFile(COOKIE_FILE, "utf-8").then((s) => s.trim()).catch(() => ""); 67 + await unlink(COOKIE_FILE).catch(() => {}); 68 + await unlink(CONFIG_FILE).catch(() => {}); 69 + 70 + if (!cookie) throw new Error(`Sign-in cancelled. Log:\n${log}`); 69 71 return cookie; 70 72 } 71 73 72 74 async function ensureBinary(): Promise<string> { 73 75 if (!environment.isDevelopment) { 74 - // Production: use the pre-compiled universal binary bundled in assets. 75 - // Re-sign ad-hoc if the signature was stripped (e.g. by Raycast's distribution pipeline). 76 76 const binaryPath = path.join(environment.assetsPath, "auth-browser-bin"); 77 77 const valid = await execAsync(`codesign --verify "${binaryPath}"`).then(() => true).catch(() => false); 78 78 if (!valid) await execAsync(`codesign --sign - --force "${binaryPath}"`); 79 79 return binaryPath; 80 80 } 81 81 82 - // Development: compile from source so changes to the Swift file are picked up 83 82 const swiftSrc = path.join(environment.assetsPath, "auth-browser.swift"); 84 83 const binaryPath = path.join(environment.supportPath, "auth-browser"); 85 84 ··· 88 87 return binaryPath; 89 88 } catch { 90 89 await mkdir(environment.supportPath, { recursive: true }); 91 - // Compile with optimisations to avoid debug-mode assertion traps 92 90 await execAsync(`swiftc -O "${swiftSrc}" -o "${binaryPath}"`); 93 - // Ad-hoc sign so macOS treats it as a trusted binary 94 91 await execAsync(`codesign --sign - --force "${binaryPath}"`); 95 92 return binaryPath; 96 93 } 97 94 } 98 95 99 - async function ensureAppBundle(binaryPath: string, sessionId: string): Promise<string> { 100 - const appDir = path.join(os.tmpdir(), `CedarStalkAuth-${sessionId}.app`); 96 + async function ensureAppBundle(binaryPath: string): Promise<string> { 97 + const appDir = path.join(os.tmpdir(), "CedarStalkAuth.app"); 101 98 const macosDir = path.join(appDir, "Contents", "MacOS"); 102 99 const plistPath = path.join(appDir, "Contents", "Info.plist"); 103 100 const bundledBinary = path.join(macosDir, "CedarStalkAuth"); 104 101 102 + // Always recreate fresh so LS sees a new bundle 103 + await execAsync(`rm -rf "${appDir}"`).catch(() => {}); 105 104 await mkdir(macosDir, { recursive: true }); 106 - 107 - // Always recreate the symlink so it tracks the current binary path 108 - await unlink(bundledBinary).catch(() => {}); 109 105 await symlink(binaryPath, bundledBinary); 110 106 111 - await writeFile( 112 - plistPath, 113 - `<?xml version="1.0" encoding="UTF-8"?> 107 + await writeFile(plistPath, `<?xml version="1.0" encoding="UTF-8"?> 114 108 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 115 109 <plist version="1.0"> 116 110 <dict> 117 - <key>CFBundlePackageType</key><string>APPL</string> 118 - <key>CFBundleExecutable</key><string>CedarStalkAuth</string> 119 - <key>CFBundleIdentifier</key><string>sh.dunkirk.cedarstalk.auth</string> 120 - <key>CFBundleName</key><string>CedarStalk Auth</string> 121 - <key>NSPrincipalClass</key><string>NSApplication</string> 122 - <key>NSHighResolutionCapable</key><true/> 123 - <key>LSMinimumSystemVersion</key><string>13.0</string> 111 + \t<key>CFBundlePackageType</key><string>APPL</string> 112 + \t<key>CFBundleExecutable</key><string>CedarStalkAuth</string> 113 + \t<key>CFBundleIdentifier</key><string>sh.dunkirk.cedarstalk.auth</string> 114 + \t<key>CFBundleName</key><string>CedarStalk Auth</string> 115 + \t<key>NSPrincipalClass</key><string>NSApplication</string> 116 + \t<key>NSHighResolutionCapable</key><true/> 117 + \t<key>LSMinimumSystemVersion</key><string>13.0</string> 124 118 </dict> 125 - </plist>`, 126 - ); 119 + </plist>`); 127 120 128 121 return appDir; 129 122 }
+25 -37
src/search-directory.tsx
··· 26 26 } from "./api"; 27 27 import { 28 28 clearCookie, 29 + drainPendingCookie, 29 30 getStoredCookie, 30 31 launchAuthBrowser, 31 32 storeCookie, ··· 33 34 import { getCacheSize, mergePeopleIntoCache, searchCache } from "./cache"; 34 35 import { getCachedPhotoPath } from "./images"; 35 36 37 + const SIGN_IN_URL = "https://selfservice.cedarville.edu/cedarinfo/directory"; 38 + 36 39 type AuthState = 37 40 | { kind: "loading" } 38 41 | { kind: "ready"; cookie: string } 39 - | { kind: "sign-in"; signInUrl: string } 42 + | { kind: "sign-in" } 40 43 | { kind: "signing-in" }; 41 44 42 45 const CLASS_LABELS: Record<string, string> = { ··· 575 578 576 579 useEffect(() => { 577 580 (async () => { 578 - const cookie = await getStoredCookie(); 581 + let cookie = await getStoredCookie(); 582 + 583 + // Raycast may have closed mid-auth while the Swift browser was still 584 + // running. If it finished and wrote the cookie file, pick it up now. 585 + if (!cookie) { 586 + const pending = await drainPendingCookie(); 587 + if (pending) { 588 + await storeCookie(pending); 589 + cookie = pending; 590 + } 591 + } 592 + 579 593 if (cookie) { 580 594 setAuthState({ kind: "ready", cookie }); 581 595 setCacheSize(await getCacheSize()); ··· 583 597 getPopulations(cookie).then(setPopulations); 584 598 return; 585 599 } 586 - try { 587 - await searchDirectory("probe", "probe"); 588 - setAuthState({ 589 - kind: "sign-in", 590 - signInUrl: "https://selfservice.cedarville.edu/cedarinfo/directory", 591 - }); 592 - } catch (err) { 593 - setAuthState({ 594 - kind: "sign-in", 595 - signInUrl: 596 - err instanceof AuthRequiredError 597 - ? err.signInUrl 598 - : "https://selfservice.cedarville.edu/cedarinfo/directory", 599 - }); 600 - } 600 + setAuthState({ kind: "sign-in" }); 601 601 })(); 602 602 }, []); 603 603 ··· 671 671 if (controller.signal.aborted) return; 672 672 if (err instanceof AuthRequiredError) { 673 673 await clearCookie(); 674 - setAuthState({ kind: "sign-in", signInUrl: err.signInUrl }); 674 + setAuthState({ kind: "sign-in" }); 675 675 } else { 676 676 await showToast({ 677 677 style: Toast.Style.Failure, ··· 705 705 async function handleSignOut() { 706 706 await clearCookie(); 707 707 setResults([]); 708 - try { 709 - await searchDirectory("probe", "probe"); 710 - setAuthState({ 711 - kind: "sign-in", 712 - signInUrl: "https://selfservice.cedarville.edu/cedarinfo/directory", 713 - }); 714 - } catch (err) { 715 - setAuthState({ 716 - kind: "sign-in", 717 - signInUrl: 718 - err instanceof AuthRequiredError 719 - ? err.signInUrl 720 - : "https://selfservice.cedarville.edu/cedarinfo/directory", 721 - }); 722 - } 708 + // Always use the base URL — let the server issue a fresh SAML redirect 709 + // when the browser opens, rather than using a potentially stale one. 710 + setAuthState({ kind: "sign-in" }); 723 711 } 724 712 725 - async function handleSignIn(signInUrl: string) { 713 + async function handleSignIn() { 726 714 setAuthState({ kind: "signing-in" }); 727 715 const toast = await showToast({ 728 716 style: Toast.Style.Animated, ··· 730 718 message: "Complete login in the window that opens", 731 719 }); 732 720 try { 733 - const cookie = await launchAuthBrowser(signInUrl); 721 + const cookie = await launchAuthBrowser(SIGN_IN_URL); 734 722 await storeCookie(cookie); 735 723 toast.style = Toast.Style.Success; 736 724 toast.title = "Signed in!"; ··· 741 729 } catch (err) { 742 730 toast.style = Toast.Style.Failure; 743 731 toast.title = String(err); 744 - setAuthState({ kind: "sign-in", signInUrl }); 732 + setAuthState({ kind: "sign-in" }); 745 733 } 746 734 } 747 735 ··· 761 749 <Action 762 750 title="Sign In" 763 751 icon={Icon.Person} 764 - onAction={() => handleSignIn(authState.signInUrl)} 752 + onAction={handleSignIn} 765 753 /> 766 754 </ActionPanel> 767 755 }