mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat: add picoclaw-launcher with web UI for configuration and gateway management (#904)
A standalone web-based tool for managing picoclaw configuration, OAuth authentication providers, and gateway process lifecycle. Features include a sidebar layout with i18n (en/zh) and theme support, real-time gateway log streaming, startup prerequisites checks, and Windows icon embedding. Co-authored-by: wj-xiao <meetwenjie@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+64
-8
@@ -66,7 +66,8 @@ func decodeBase64(s string) string {
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func generateState() (string, error) {
|
||||
// GenerateState generates a random state string for OAuth CSRF protection.
|
||||
func GenerateState() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
@@ -80,7 +81,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||
return nil, fmt.Errorf("generating PKCE: %w", err)
|
||||
}
|
||||
|
||||
state, err := generateState()
|
||||
state, err := GenerateState()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating state: %w", err)
|
||||
}
|
||||
@@ -127,7 +128,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||
|
||||
fmt.Printf("Open this URL to authenticate:\n\n%s\n\n", authURL)
|
||||
|
||||
if err := openBrowser(authURL); err != nil {
|
||||
if err := OpenBrowser(authURL); err != nil {
|
||||
fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL)
|
||||
}
|
||||
|
||||
@@ -153,7 +154,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||
if result.err != nil {
|
||||
return nil, result.err
|
||||
}
|
||||
return exchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
|
||||
return ExchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
|
||||
case manualInput := <-manualCh:
|
||||
if manualInput == "" {
|
||||
return nil, fmt.Errorf("manual input canceled")
|
||||
@@ -169,7 +170,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||
if code == "" {
|
||||
return nil, fmt.Errorf("could not find authorization code in input")
|
||||
}
|
||||
return exchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI)
|
||||
return ExchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI)
|
||||
case <-time.After(5 * time.Minute):
|
||||
return nil, fmt.Errorf("authentication timed out after 5 minutes")
|
||||
}
|
||||
@@ -186,6 +187,59 @@ type deviceCodeResponse struct {
|
||||
Interval int
|
||||
}
|
||||
|
||||
// DeviceCodeInfo holds the device code information returned by the OAuth provider.
|
||||
type DeviceCodeInfo struct {
|
||||
DeviceAuthID string `json:"device_auth_id"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerifyURL string `json:"verify_url"`
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
|
||||
// RequestDeviceCode requests a device code from the OAuth provider.
|
||||
// Returns the info needed for the user to authenticate in a browser.
|
||||
func RequestDeviceCode(cfg OAuthProviderConfig) (*DeviceCodeInfo, error) {
|
||||
reqBody, _ := json.Marshal(map[string]string{
|
||||
"client_id": cfg.ClientID,
|
||||
})
|
||||
|
||||
resp, err := http.Post(
|
||||
cfg.Issuer+"/api/accounts/deviceauth/usercode",
|
||||
"application/json",
|
||||
strings.NewReader(string(reqBody)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("requesting device code: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
||||
}
|
||||
|
||||
deviceResp, err := parseDeviceCodeResponse(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing device code response: %w", err)
|
||||
}
|
||||
|
||||
if deviceResp.Interval < 1 {
|
||||
deviceResp.Interval = 5
|
||||
}
|
||||
|
||||
return &DeviceCodeInfo{
|
||||
DeviceAuthID: deviceResp.DeviceAuthID,
|
||||
UserCode: deviceResp.UserCode,
|
||||
VerifyURL: cfg.Issuer + "/codex/device",
|
||||
Interval: deviceResp.Interval,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PollDeviceCodeOnce makes a single poll attempt to check if the user has authenticated.
|
||||
// Returns (credential, nil) on success, (nil, nil) if still pending, or (nil, err) on failure.
|
||||
func PollDeviceCodeOnce(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*AuthCredential, error) {
|
||||
return pollDeviceCode(cfg, deviceAuthID, userCode)
|
||||
}
|
||||
|
||||
func parseDeviceCodeResponse(body []byte) (deviceCodeResponse, error) {
|
||||
var raw struct {
|
||||
DeviceAuthID string `json:"device_auth_id"`
|
||||
@@ -318,7 +372,7 @@ func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*Au
|
||||
}
|
||||
|
||||
redirectURI := cfg.Issuer + "/deviceauth/callback"
|
||||
return exchangeCodeForTokens(cfg, tokenResp.AuthorizationCode, tokenResp.CodeVerifier, redirectURI)
|
||||
return ExchangeCodeForTokens(cfg, tokenResp.AuthorizationCode, tokenResp.CodeVerifier, redirectURI)
|
||||
}
|
||||
|
||||
func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||
@@ -410,7 +464,8 @@ func buildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectU
|
||||
return cfg.Issuer + "/oauth/authorize?" + params.Encode()
|
||||
}
|
||||
|
||||
func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) {
|
||||
// ExchangeCodeForTokens exchanges an authorization code for tokens.
|
||||
func ExchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) {
|
||||
data := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
@@ -552,7 +607,8 @@ func base64URLDecode(s string) ([]byte, error) {
|
||||
return base64.StdEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
func openBrowser(url string) error {
|
||||
// OpenBrowser opens the given URL in the user's default browser.
|
||||
func OpenBrowser(url string) error {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return exec.Command("open", url).Start()
|
||||
|
||||
@@ -219,9 +219,9 @@ func TestExchangeCodeForTokens(t *testing.T) {
|
||||
Port: 1455,
|
||||
}
|
||||
|
||||
cred, err := exchangeCodeForTokens(cfg, "test-code", "test-verifier", "http://localhost:1455/auth/callback")
|
||||
cred, err := ExchangeCodeForTokens(cfg, "test-code", "test-verifier", "http://localhost:1455/auth/callback")
|
||||
if err != nil {
|
||||
t.Fatalf("exchangeCodeForTokens() error: %v", err)
|
||||
t.Fatalf("ExchangeCodeForTokens() error: %v", err)
|
||||
}
|
||||
|
||||
if cred.AccessToken != "mock-access-token" {
|
||||
|
||||
Reference in New Issue
Block a user