diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0dd4b49..8517be2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,6 +59,42 @@ jobs: librsvg2-dev \ patchelf + - name: Import Apple certificate (macOS only) + if: contains(matrix.target, 'apple-darwin') + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12 + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + + # Decode certificate + echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERTIFICATE_PATH" + + # Create temporary keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Import certificate + security import "$CERTIFICATE_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" \ + -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: \ + -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychain -d user -s "$KEYCHAIN_PATH" login.keychain + + # Clean up certificate file + rm "$CERTIFICATE_PATH" + + - name: Write Apple API key (macOS only) + if: contains(matrix.target, 'apple-darwin') + env: + APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }} + run: | + mkdir -p ~/.private_keys + echo "$APPLE_API_KEY_CONTENT" > ~/.private_keys/AuthKey_${{ secrets.APPLE_API_KEY }}.p8 + - name: Install frontend dependencies run: npm ci @@ -67,6 +103,12 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: '' + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY_PATH: ~/.private_keys/AuthKey_${{ secrets.APPLE_API_KEY }}.p8 with: tagName: ${{ github.ref_name }} releaseName: ClawPal ${{ github.ref_name }} @@ -75,13 +117,13 @@ jobs: ### Installation - **macOS** (unsigned — requires manual approval): + **macOS**: - Download the `.dmg` for your architecture (ARM for Apple Silicon, x64 for Intel) - Open the DMG and drag ClawPal to Applications - - First launch: right-click the app → Open, or run `xattr -cr /Applications/ClawPal.app` **Windows** (unsigned — SmartScreen will warn): - - Download the `.exe` (NSIS installer) or `.msi` + - **Portable**: Download `ClawPal_portable_x64.exe` — no install needed (requires WebView2) + - **Installer**: Download the NSIS `.exe` or `.msi` - If SmartScreen blocks: click "More info" → "Run anyway" **Linux**: @@ -90,3 +132,22 @@ jobs: releaseDraft: true prerelease: false args: --target ${{ matrix.target }} + + - name: Upload Windows portable exe + if: matrix.platform == 'windows-latest' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + $exe = Get-ChildItem "src-tauri/target/${{ matrix.target }}/release/clawpal.exe" -ErrorAction SilentlyContinue + if ($exe) { + $dest = "ClawPal_portable_x64.exe" + Copy-Item $exe.FullName $dest + gh release upload ${{ github.ref_name }} $dest --clobber + } + + - name: Cleanup Apple signing (macOS only) + if: ${{ contains(matrix.target, 'apple-darwin') && always() }} + run: | + security delete-keychain $RUNNER_TEMP/app-signing.keychain-db 2>/dev/null || true + rm -f ~/.private_keys/AuthKey_*.p8 2>/dev/null || true diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0a2f429..6b5f4bb 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2204,16 +2204,22 @@ fn parse_openclaw_update_text(raw: &str) -> Option<(Option, String, Stri } fn query_openclaw_latest_npm() -> Result, String> { - let output = run_external_command_raw(&["npm", "view", "openclaw", "version"]); - let output = match output { - Ok(output) => output, - Err(_) => return Ok(None), - }; - if output.stdout.trim().is_empty() { + // Query npm registry directly via HTTP — no local npm CLI needed + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| format!("HTTP client error: {e}"))?; + let resp = client + .get("https://registry.npmjs.org/openclaw/latest") + .header("Accept", "application/json") + .send() + .map_err(|e| format!("npm registry request failed: {e}"))?; + if !resp.status().is_success() { return Ok(None); } - let trimmed = output.stdout.trim().trim_matches(['\"', '\''].as_ref()); - Ok(Some(trimmed.to_string())) + let body: Value = resp.json().map_err(|e| format!("npm registry parse failed: {e}"))?; + let version = body.get("version").and_then(Value::as_str).map(String::from); + Ok(version) } /// Fetch a Discord guild name via the Discord REST API using a bot token. diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 5114d42..58df83e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,3 +1,5 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + fn main() { clawpal::run(); } diff --git a/src-tauri/src/ssh.rs b/src-tauri/src/ssh.rs index a0f5463..14d6f84 100644 --- a/src-tauri/src/ssh.rs +++ b/src-tauri/src/ssh.rs @@ -154,25 +154,41 @@ impl SshConnectionPool { .map_err(|e| format!("Public key auth failed: {e}"))? } "ssh_config" => { - // Try IdentityFile from ~/.ssh/config first, then fall back to agent + // Try IdentityFile from ~/.ssh/config, then default key paths, then agent let identity_file = parse_ssh_config_identity(&config.host); - if let Some(key_path) = identity_file { - let expanded = shellexpand::tilde(&key_path).to_string(); - match russh::keys::load_secret_key(&expanded, None) { - Ok(key_pair) => { - session - .authenticate_publickey(&username, Arc::new(key_pair)) - .await - .map_err(|e| format!("Public key auth failed: {e}"))? - } - Err(_) => { - // Key file failed to load, try agent as fallback - self.authenticate_with_agent(&mut session, &username).await? + + // Build list of key paths to try + let mut key_paths: Vec = Vec::new(); + if let Some(ref p) = identity_file { + key_paths.push(shellexpand::tilde(p).to_string()); + } + // Default key paths (same order as OpenSSH) + let home = shellexpand::tilde("~").to_string(); + for name in ["id_ed25519", "id_rsa", "id_ecdsa"] { + let p = format!("{home}/.ssh/{name}"); + if !key_paths.contains(&p) { + key_paths.push(p); + } + } + + let mut authenticated = false; + for path in &key_paths { + if let Ok(key_pair) = russh::keys::load_secret_key(path, None) { + match session + .authenticate_publickey(&username, Arc::new(key_pair)) + .await + { + Ok(true) => { authenticated = true; break; } + _ => continue, } } - } else { - // No IdentityFile in config, try agent + } + + if !authenticated { + // All key files failed, try SSH agent as last resort self.authenticate_with_agent(&mut session, &username).await? + } else { + true } } "password" => { @@ -210,9 +226,28 @@ impl SshConnectionPool { session: &mut client::Handle, username: &str, ) -> Result { - let mut agent = russh::keys::agent::client::AgentClient::connect_env() - .await - .map_err(|e| format!("Could not connect to SSH agent: {e}"))?; + let mut agent = match russh::keys::agent::client::AgentClient::connect_env().await { + Ok(a) => a, + Err(_) => { + // GUI apps may not have SSH_AUTH_SOCK; ask launchd for the socket path + let output = tokio::task::spawn_blocking(|| { + std::process::Command::new("launchctl") + .args(["getenv", "SSH_AUTH_SOCK"]) + .output() + }).await + .map_err(|e| format!("Could not determine SSH agent socket: {e}"))? + .map_err(|e| format!("Could not determine SSH agent socket: {e}"))?; + let sock_path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if sock_path.is_empty() { + return Err("Could not connect to SSH agent: SSH_AUTH_SOCK not set and launchctl lookup failed".into()); + } + russh::keys::agent::client::AgentClient::connect( + tokio::net::UnixStream::connect(&sock_path) + .await + .map_err(|e| format!("Could not connect to SSH agent at {sock_path}: {e}"))? + ) + } + }; let identities = agent .request_identities() diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 85cea72..eca523c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -30,7 +30,7 @@ "targets": "all", "macOS": { "minimumSystemVersion": "10.15", - "signingIdentity": null + "signingIdentity": "Developer ID Application: UNIPASS TECH PTE. LTD. (X53M8444K4)" }, "windows": { "certificateThumbprint": null, diff --git a/src/App.tsx b/src/App.tsx index 3c9de68..f80a71e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -63,6 +63,22 @@ export function App() { refreshHosts(); }, [refreshHosts]); + const [toasts, setToasts] = useState([]); + + const showToast = useCallback((message: string, type: "success" | "error" = "success") => { + const id = ++toastIdCounter; + setToasts((prev) => [...prev, { id, message, type }]); + if (type !== "error") { + setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, 3000); + } + }, []); + + const dismissToast = useCallback((id: number) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + const handleInstanceSelect = useCallback((id: string) => { setActiveInstance(id); if (id !== "local") { @@ -70,9 +86,12 @@ export function App() { setConnectionStatus((prev) => ({ ...prev, [id]: "disconnected" })); api.sshConnect(id) .then(() => setConnectionStatus((prev) => ({ ...prev, [id]: "connected" }))) - .catch(() => setConnectionStatus((prev) => ({ ...prev, [id]: "error" }))); + .catch((e) => { + setConnectionStatus((prev) => ({ ...prev, [id]: "error" })); + showToast(`SSH connection failed: ${e}`, "error"); + }); } - }, []); + }, [showToast]); // Config dirty state const [dirty, setDirty] = useState(false); @@ -83,7 +102,6 @@ export function App() { const [applying, setApplying] = useState(false); const [applyError, setApplyError] = useState(""); const [configVersion, setConfigVersion] = useState(0); - const [toasts, setToasts] = useState([]); const pollRef = useRef | null>(null); const isRemote = activeInstance !== "local"; @@ -157,18 +175,6 @@ export function App() { .catch((e) => console.error("Failed to load config diff:", e)); }; - const showToast = useCallback((message: string, type: "success" | "error" = "success") => { - const id = ++toastIdCounter; - setToasts((prev) => [...prev, { id, message, type }]); - setTimeout(() => { - setToasts((prev) => prev.filter((t) => t.id !== id)); - }, 3000); - }, []); - - const dismissToast = useCallback((id: number) => { - setToasts((prev) => prev.filter((t) => t.id !== id)); - }, []); - const handleApplyConfirm = () => { setApplying(true); setApplyError(""); diff --git a/src/pages/Channels.tsx b/src/pages/Channels.tsx index 17f9fa5..90fd00b 100644 --- a/src/pages/Channels.tsx +++ b/src/pages/Channels.tsx @@ -62,7 +62,7 @@ export function Channels({ const [bindings, setBindings] = useState([]); const [modelProfiles, setModelProfiles] = useState([]); const [channelNodes, setChannelNodes] = useState([]); - const [discordChannels, setDiscordChannels] = useState([]); + const [discordChannels, setDiscordChannels] = useState(null); const [refreshing, setRefreshing] = useState(null); const [saving, setSaving] = useState(null); @@ -160,7 +160,7 @@ export function Channels({ // Discord channels grouped by guild const discordGuilds = useMemo(() => { const map = new Map(); - for (const ch of discordChannels) { + for (const ch of discordChannels || []) { if (!map.has(ch.guildId)) { map.set(ch.guildId, { guildName: ch.guildName, channels: [] }); } @@ -260,7 +260,7 @@ export function Channels({ ); }; - const hasDiscord = discordChannels.length > 0; + const hasDiscord = (discordChannels || []).length > 0; const hasOther = otherPlatforms.length > 0; return ( @@ -275,7 +275,6 @@ export function Channels({
{/* Discord section — only show for local or when Discord data exists */} - {(!isRemote || hasDiscord) && (
@@ -293,7 +292,9 @@ export function Channels({ )}
- {discordGuilds.length === 0 ? ( + {discordChannels === null ? ( +

Loading Discord channels...

+ ) : discordGuilds.length === 0 ? (

No Discord channels cached. Click "Refresh" to discover channels from Discord.

@@ -320,7 +321,6 @@ export function Channels({ )}
- )} {/* Other platform sections */} {otherPlatforms.map(([platform, nodes]) => ( @@ -376,7 +376,7 @@ export function Channels({ if (pendingChannel) { handleAssign(pendingChannel.platform, pendingChannel.peerId, result.agentId); if (result.persona && pendingChannel.platform === "discord") { - const ch = discordChannels.find((c) => c.channelId === pendingChannel.peerId); + const ch = (discordChannels || []).find((c) => c.channelId === pendingChannel.peerId); if (ch) { const patch = JSON.stringify({ channels: { discord: { guilds: { [ch.guildId]: { channels: { [ch.channelId]: { systemPrompt: result.persona } } } } } }, diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 2038880..1826c6f 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -62,6 +62,7 @@ export function Home({ const [status, setStatus] = useState(null); const [version, setVersion] = useState(null); const [updateInfo, setUpdateInfo] = useState<{ available: boolean; latest?: string } | null>(null); + const [checkingUpdate, setCheckingUpdate] = useState(false); const [agents, setAgents] = useState(null); const [recipes, setRecipes] = useState([]); const [backups, setBackups] = useState(null); @@ -161,12 +162,15 @@ export function Home({ // Update check — deferred, runs once (not in poll loop) useEffect(() => { + setCheckingUpdate(true); + setUpdateInfo(null); const timer = setTimeout(() => { if (isRemote) { - if (!isConnected) return; + if (!isConnected) { setCheckingUpdate(false); return; } api.remoteCheckOpenclawUpdate(instanceId).then((u) => { setUpdateInfo({ available: u.upgradeAvailable, latest: u.latestVersion ?? undefined }); - }).catch((e) => console.error("Failed to check remote update:", e)); + }).catch((e) => console.error("Failed to check remote update:", e)) + .finally(() => setCheckingUpdate(false)); } else { api.getSystemStatus().then((s) => { setVersion(s.openclawVersion); @@ -176,7 +180,8 @@ export function Home({ latest: s.openclawUpdate.latestVersion, }); } - }).catch((e) => console.error("Failed to fetch system status:", e)); + }).catch((e) => console.error("Failed to fetch system status:", e)) + .finally(() => setCheckingUpdate(false)); } }, 500); return () => clearTimeout(timer); @@ -214,7 +219,10 @@ export function Home({ Version
{version || "..."} - {updateInfo?.available && updateInfo.latest && updateInfo.latest !== version && ( + {checkingUpdate && ( + Checking for updates... + )} + {!checkingUpdate && updateInfo?.available && updateInfo.latest && updateInfo.latest !== version && ( <> {updateInfo.latest} available diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 809fef6..81d01c6 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -116,7 +116,7 @@ function AutocompleteField({ export function Settings({ onDataChange }: { onDataChange?: () => void }) { const { instanceId, isRemote, isConnected } = useInstance(); - const [profiles, setProfiles] = useState([]); + const [profiles, setProfiles] = useState(null); const [catalog, setCatalog] = useState([]); const [apiKeys, setApiKeys] = useState([]); const [form, setForm] = useState(emptyForm()); @@ -190,7 +190,7 @@ export function Settings({ onDataChange }: { onDataChange?: () => void }) { } if (isRemote) { // For remote: check if any existing profile with the same provider already has a key - const sameProviderProfile = profiles.find( + const sameProviderProfile = (profiles || []).find( (p) => p.provider === form.provider && maskedKeyMap.has(p.id) && maskedKeyMap.get(p.id) !== "..." ); if (sameProviderProfile) { @@ -419,11 +419,13 @@ export function Settings({ onDataChange }: { onDataChange?: () => void }) { Model Profiles - {profiles.length === 0 && ( + {profiles === null ? ( +

Loading profiles...

+ ) : profiles.length === 0 ? (

No model profiles yet.

- )} + ) : null}
- {profiles.map((profile) => ( + {(profiles || []).map((profile) => (