mirror of
https://github.com/aljazceru/goose.git
synced 2025-12-18 22:54:24 +01:00
feat: goose windows (#880)
Co-authored-by: Ryan Versaw <ryan@versaw.com>
This commit is contained in:
14
.github/workflows/build-cli.yml
vendored
14
.github/workflows/build-cli.yml
vendored
@@ -15,7 +15,7 @@ on:
|
||||
operating-systems:
|
||||
type: string
|
||||
required: false
|
||||
default: '["ubuntu-latest","macos-latest"]'
|
||||
default: '["ubuntu-latest","macos-latest","windows-latest"]'
|
||||
architectures:
|
||||
type: string
|
||||
required: false
|
||||
@@ -37,6 +37,8 @@ jobs:
|
||||
target-suffix: unknown-linux-gnu
|
||||
- os: macos-latest
|
||||
target-suffix: apple-darwin
|
||||
- os: windows-latest
|
||||
target-suffix: pc-windows-gnu
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -60,12 +62,20 @@ jobs:
|
||||
- name: Build CLI
|
||||
env:
|
||||
CROSS_NO_WARNINGS: 0
|
||||
RUST_LOG: debug
|
||||
RUST_BACKTRACE: 1
|
||||
CROSS_VERBOSE: 1
|
||||
run: |
|
||||
export TARGET="${{ matrix.architecture }}-${{ matrix.target-suffix }}"
|
||||
rustup target add "${TARGET}"
|
||||
echo "Building for target: ${TARGET}"
|
||||
echo "Rust toolchain info:"
|
||||
rustup show
|
||||
echo "Cross version:"
|
||||
cross --version
|
||||
|
||||
# 'cross' is used to cross-compile for different architectures (see Cross.toml)
|
||||
cross build --release --target ${TARGET} -p goose-cli
|
||||
cross build --release --target ${TARGET} -p goose-cli -vv
|
||||
|
||||
# tar the goose binary as goose-<TARGET>.tar.bz2
|
||||
cd target/${TARGET}/release
|
||||
|
||||
157
.github/workflows/bundle-desktop-windows.yml
vendored
Normal file
157
.github/workflows/bundle-desktop-windows.yml
vendored
Normal file
@@ -0,0 +1,157 @@
|
||||
name: "Bundle Desktop (Windows)"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
workflow_call:
|
||||
inputs:
|
||||
signing:
|
||||
description: 'Whether to sign the Windows executable'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
secrets:
|
||||
WINDOWS_CERTIFICATE:
|
||||
required: false
|
||||
WINDOWS_CERTIFICATE_PASSWORD:
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
build-desktop-windows:
|
||||
name: Build Desktop (Windows)
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
# 1) Check out source
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# 2) Set up Rust
|
||||
- name: Set up Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
# If you need a specific version, you could do:
|
||||
# or uses: actions/setup-rust@v1
|
||||
# with:
|
||||
# rust-version: 1.73.0
|
||||
|
||||
# 3) Set up Node.js
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
# 4) Cache dependencies (optional, can add more paths if needed)
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
ui/desktop/node_modules
|
||||
key: ${{ runner.os }}-build-desktop-windows-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-desktop-windows-
|
||||
|
||||
# 5) Install top-level dependencies if a package.json is in root
|
||||
- name: Install top-level deps
|
||||
run: |
|
||||
if (Test-Path package.json) {
|
||||
npm install
|
||||
}
|
||||
|
||||
# 6) Build rust for x86_64-pc-windows-gnu
|
||||
- name: Install MinGW dependencies
|
||||
run: |
|
||||
choco install mingw --version=8.1.0
|
||||
# Debug - check installation paths
|
||||
Write-Host "Checking MinGW installation..."
|
||||
Get-ChildItem -Path "C:\ProgramData\chocolatey\lib\mingw" -Recurse -Filter "*.dll" | ForEach-Object {
|
||||
Write-Host $_.FullName
|
||||
}
|
||||
Get-ChildItem -Path "C:\tools" -Recurse -Filter "*.dll" | ForEach-Object {
|
||||
Write-Host $_.FullName
|
||||
}
|
||||
|
||||
- name: Cargo build for Windows
|
||||
run: |
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
|
||||
# 7) Check that the compiled goosed.exe exists and copy exe/dll to ui/desktop/src/bin
|
||||
- name: Prepare Windows binary and DLLs
|
||||
run: |
|
||||
if (!(Test-Path .\target\x86_64-pc-windows-gnu\release\goosed.exe)) {
|
||||
Write-Error "Windows binary not found."; exit 1;
|
||||
}
|
||||
Write-Host "Copying Windows binary and DLLs to ui/desktop/src/bin..."
|
||||
if (!(Test-Path ui\desktop\src\bin)) {
|
||||
New-Item -ItemType Directory -Path ui\desktop\src\bin | Out-Null
|
||||
}
|
||||
Copy-Item .\target\x86_64-pc-windows-gnu\release\goosed.exe ui\desktop\src\bin\
|
||||
|
||||
# Copy MinGW DLLs - try both possible locations
|
||||
$mingwPaths = @(
|
||||
"C:\ProgramData\chocolatey\lib\mingw\tools\install\mingw64\bin",
|
||||
"C:\tools\mingw64\bin"
|
||||
)
|
||||
|
||||
foreach ($path in $mingwPaths) {
|
||||
if (Test-Path "$path\libstdc++-6.dll") {
|
||||
Write-Host "Found MinGW DLLs in $path"
|
||||
Copy-Item "$path\libstdc++-6.dll" ui\desktop\src\bin\
|
||||
Copy-Item "$path\libgcc_s_seh-1.dll" ui\desktop\src\bin\
|
||||
Copy-Item "$path\libwinpthread-1.dll" ui\desktop\src\bin\
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# Copy any other DLLs from the release directory
|
||||
ls .\target\x86_64-pc-windows-gnu\release\*.dll | ForEach-Object {
|
||||
Copy-Item $_ ui\desktop\src\bin\
|
||||
}
|
||||
|
||||
# 8) Install & build UI desktop
|
||||
- name: Build desktop UI with npm
|
||||
run: |
|
||||
cd ui\desktop
|
||||
npm install
|
||||
npm run bundle:windows
|
||||
|
||||
# 9) Copy exe/dll to final out/Goose-win32-x64/resources/bin
|
||||
- name: Copy exe/dll to out folder
|
||||
run: |
|
||||
cd ui\desktop
|
||||
if (!(Test-Path .\out\Goose-win32-x64\resources\bin)) {
|
||||
New-Item -ItemType Directory -Path .\out\Goose-win32-x64\resources\bin | Out-Null
|
||||
}
|
||||
Copy-Item .\src\bin\goosed.exe .\out\Goose-win32-x64\resources\bin\
|
||||
ls .\src\bin\*.dll | ForEach-Object {
|
||||
Copy-Item $_ .\out\Goose-win32-x64\resources\bin\
|
||||
}
|
||||
|
||||
# 10) Code signing (if enabled)
|
||||
- name: Sign Windows executable
|
||||
# Skip this step by default - enable when we have a certificate
|
||||
if: inputs.signing && inputs.signing == true
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
# Create a temporary certificate file
|
||||
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
|
||||
$certPath = Join-Path -Path $env:RUNNER_TEMP -ChildPath "certificate.pfx"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Sign the main executable
|
||||
$signtool = "C:\Program Files (x86)\Windows Kits\10\bin\10.0.17763.0\x64\signtool.exe"
|
||||
& $signtool sign /f $certPath /p $env:WINDOWS_CERTIFICATE_PASSWORD /tr http://timestamp.digicert.com /td sha256 /fd sha256 "ui\desktop\out\Goose-win32-x64\Goose.exe"
|
||||
|
||||
# Clean up the certificate
|
||||
Remove-Item -Path $certPath
|
||||
|
||||
# 11) Upload the final Windows build
|
||||
- name: Upload Windows build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-windows-dist
|
||||
path: ui/desktop/out/Goose-win32-x64/
|
||||
16
.github/workflows/bundle-desktop.yml
vendored
16
.github/workflows/bundle-desktop.yml
vendored
@@ -201,21 +201,5 @@ jobs:
|
||||
echo "App did not stay open. Possible crash or startup error."
|
||||
exit 1
|
||||
fi
|
||||
LOGFILE="$HOME/Library/Application Support/Goose/logs/main.log"
|
||||
# Print the log and verify "ChatWindow loaded" is in the logs
|
||||
if [ -f "$LOGFILE" ]; then
|
||||
echo "===== Log file contents ====="
|
||||
cat "$LOGFILE"
|
||||
echo "============================="
|
||||
if grep -F "ChatWindow loaded" "$LOGFILE"; then
|
||||
echo "Confirmed: 'ChatWindow loaded' found in logs!"
|
||||
else
|
||||
echo "Did not find 'ChatWindow loaded' in logs. Failing..."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "No log file found at $LOGFILE. Exiting with failure."
|
||||
exit 1
|
||||
fi
|
||||
# Kill the app to clean up
|
||||
pkill -f "Goose.app/Contents/MacOS/Goose"
|
||||
22
.github/workflows/pr-comment-bundle-desktop.yml
vendored
22
.github/workflows/pr-comment-bundle-desktop.yml
vendored
@@ -51,10 +51,21 @@ jobs:
|
||||
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
bundle-desktop-windows:
|
||||
# Only run this if ".bundle" command is detected.
|
||||
needs: [trigger-on-command]
|
||||
if: ${{ needs.trigger-on-command.outputs.continue == 'true' }}
|
||||
uses: ./.github/workflows/bundle-desktop-windows.yml
|
||||
with:
|
||||
signing: true
|
||||
secrets:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
|
||||
pr-comment:
|
||||
name: PR Comment with Desktop App
|
||||
runs-on: ubuntu-latest
|
||||
needs: [trigger-on-command, bundle-desktop]
|
||||
needs: [trigger-on-command, bundle-desktop, bundle-desktop-windows]
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
@@ -71,10 +82,15 @@ jobs:
|
||||
body: |
|
||||
### Desktop App for this PR
|
||||
|
||||
The following build is available for testing:
|
||||
The following builds are available for testing:
|
||||
|
||||
- [📱 macOS Desktop App (arm64, signed)](https://nightly.link/${{ github.repository }}/actions/runs/${{ github.run_id }}/Goose-darwin-arm64.zip)
|
||||
- [🪟 Windows Desktop App (x64, signed)](https://nightly.link/${{ github.repository }}/actions/runs/${{ github.run_id }}/desktop-windows-dist.zip)
|
||||
|
||||
**macOS Instructions:**
|
||||
After downloading, unzip the file and drag the Goose.app to your Applications folder. The app is signed and notarized for macOS.
|
||||
|
||||
This link is provided by nightly.link and will work even if you're not logged into GitHub.
|
||||
**Windows Instructions:**
|
||||
After downloading, unzip the file and run Goose.exe. The app is signed for Windows.
|
||||
|
||||
These links are provided by nightly.link and will work even if you're not logged into GitHub.
|
||||
|
||||
15
.github/workflows/release.yml
vendored
15
.github/workflows/release.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
path: download_cli.sh
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 3) Bundle Desktop App (macOS only)
|
||||
# 3) Bundle Desktop App (macOS)
|
||||
# ------------------------------------------------------------
|
||||
bundle-desktop:
|
||||
uses: ./.github/workflows/bundle-desktop.yml
|
||||
@@ -46,6 +46,19 @@ jobs:
|
||||
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
# # ------------------------------------------------------------
|
||||
# # 4) Bundle Desktop App (Windows)
|
||||
# # ------------------------------------------------------------
|
||||
# bundle-desktop-windows:
|
||||
# uses: ./.github/workflows/bundle-desktop-windows.yml
|
||||
# # Signing is disabled by default until we have a certificate
|
||||
# with:
|
||||
# signing: false
|
||||
# # Uncomment and configure these when we have a certificate:
|
||||
# # secrets:
|
||||
# # WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
# # WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
|
||||
# ------------------------------------
|
||||
# 4) Create/Update GitHub Release
|
||||
# ------------------------------------
|
||||
|
||||
@@ -12,7 +12,6 @@ pre-build = [
|
||||
libxcb1-dev:arm64
|
||||
"""
|
||||
]
|
||||
env = { PKG_CONFIG_PATH = "/usr/lib/aarch64-linux-gnu/pkgconfig" }
|
||||
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
xargo = false
|
||||
@@ -27,3 +26,9 @@ pre-build = [
|
||||
libxcb1-dev \
|
||||
"""
|
||||
]
|
||||
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
image = "dockcross/windows-static-x64:latest"
|
||||
# Enable verbose output for Windows builds
|
||||
build-std = true
|
||||
env = { "RUST_LOG" = "debug", "RUST_BACKTRACE" = "1", "CROSS_VERBOSE" = "1" }
|
||||
|
||||
63
Justfile
63
Justfile
@@ -10,6 +10,31 @@ release-binary:
|
||||
cargo build --release
|
||||
@just copy-binary
|
||||
|
||||
# Build Windows executable
|
||||
release-windows:
|
||||
#!/usr/bin/env sh
|
||||
if [ "$(uname)" = "Darwin" ] || [ "$(uname)" = "Linux" ]; then
|
||||
echo "Building Windows executable using Docker..."
|
||||
docker volume create goose-windows-cache || true
|
||||
docker run --rm \
|
||||
-v "$(pwd)":/usr/src/myapp \
|
||||
-v goose-windows-cache:/usr/local/cargo/registry \
|
||||
-w /usr/src/myapp \
|
||||
rust:latest \
|
||||
sh -c "rustup target add x86_64-pc-windows-gnu && \
|
||||
apt-get update && \
|
||||
apt-get install -y mingw-w64 && \
|
||||
cargo build --release --target x86_64-pc-windows-gnu && \
|
||||
GCC_DIR=\$(ls -d /usr/lib/gcc/x86_64-w64-mingw32/*/ | head -n 1) && \
|
||||
cp \$GCC_DIR/libstdc++-6.dll /usr/src/myapp/target/x86_64-pc-windows-gnu/release/ && \
|
||||
cp \$GCC_DIR/libgcc_s_seh-1.dll /usr/src/myapp/target/x86_64-pc-windows-gnu/release/ && \
|
||||
cp /usr/x86_64-w64-mingw32/lib/libwinpthread-1.dll /usr/src/myapp/target/x86_64-pc-windows-gnu/release/"
|
||||
else
|
||||
echo "Building Windows executable using Docker through PowerShell..."
|
||||
powershell.exe -Command "docker volume create goose-windows-cache; docker run --rm -v ${PWD}:/usr/src/myapp -v goose-windows-cache:/usr/local/cargo/registry -w /usr/src/myapp rust:latest sh -c 'rustup target add x86_64-pc-windows-gnu && apt-get update && apt-get install -y mingw-w64 && cargo build --release --target x86_64-pc-windows-gnu && GCC_DIR=\$(ls -d /usr/lib/gcc/x86_64-w64-mingw32/*/ | head -n 1) && cp \$GCC_DIR/libstdc++-6.dll /usr/src/myapp/target/x86_64-pc-windows-gnu/release/ && cp \$GCC_DIR/libgcc_s_seh-1.dll /usr/src/myapp/target/x86_64-pc-windows-gnu/release/ && cp /usr/x86_64-w64-mingw32/lib/libwinpthread-1.dll /usr/src/myapp/target/x86_64-pc-windows-gnu/release/'"
|
||||
fi
|
||||
echo "Windows executable and required DLLs created at ./target/x86_64-pc-windows-gnu/release/"
|
||||
|
||||
# Copy binary command
|
||||
copy-binary:
|
||||
@if [ -f ./target/release/goosed ]; then \
|
||||
@@ -20,12 +45,30 @@ copy-binary:
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
# Copy Windows binary command
|
||||
copy-binary-windows:
|
||||
@powershell.exe -Command "if (Test-Path ./target/x86_64-pc-windows-gnu/release/goosed.exe) { \
|
||||
Write-Host 'Copying Windows binary and DLLs to ui/desktop/src/bin...'; \
|
||||
Copy-Item -Path './target/x86_64-pc-windows-gnu/release/goosed.exe' -Destination './ui/desktop/src/bin/' -Force; \
|
||||
Copy-Item -Path './target/x86_64-pc-windows-gnu/release/*.dll' -Destination './ui/desktop/src/bin/' -Force; \
|
||||
} else { \
|
||||
Write-Host 'Windows binary not found.' -ForegroundColor Red; \
|
||||
exit 1; \
|
||||
}"
|
||||
|
||||
# Run UI with latest
|
||||
run-ui:
|
||||
@just release-binary
|
||||
@echo "Running UI..."
|
||||
cd ui/desktop && npm install && npm run start-gui
|
||||
|
||||
# Run UI with latest (Windows version)
|
||||
run-ui-windows:
|
||||
@just release-windows
|
||||
@powershell.exe -Command "Write-Host 'Copying Windows binary...'"
|
||||
@just copy-binary-windows
|
||||
@powershell.exe -Command "Write-Host 'Running UI...'; Set-Location ui/desktop; npm install; npm run start-gui"
|
||||
|
||||
# Run Docusaurus server for documentation
|
||||
run-docs:
|
||||
@echo "Running docs server..."
|
||||
@@ -41,6 +84,26 @@ make-ui:
|
||||
@just release-binary
|
||||
cd ui/desktop && npm run bundle:default
|
||||
|
||||
# make GUI with latest Windows binary
|
||||
make-ui-windows:
|
||||
@just release-windows
|
||||
#!/usr/bin/env sh
|
||||
if [ -f "./target/x86_64-pc-windows-gnu/release/goosed.exe" ]; then \
|
||||
echo "Copying Windows binary and DLLs to ui/desktop/src/bin..."; \
|
||||
mkdir -p ./ui/desktop/src/bin; \
|
||||
cp -f ./target/x86_64-pc-windows-gnu/release/goosed.exe ./ui/desktop/src/bin/; \
|
||||
cp -f ./target/x86_64-pc-windows-gnu/release/*.dll ./ui/desktop/src/bin/; \
|
||||
echo "Building Windows package..."; \
|
||||
cd ui/desktop && \
|
||||
npm run bundle:windows && \
|
||||
mkdir -p out/Goose-win32-x64/resources/bin && \
|
||||
cp -f src/bin/goosed.exe out/Goose-win32-x64/resources/bin/ && \
|
||||
cp -f src/bin/*.dll out/Goose-win32-x64/resources/bin/; \
|
||||
else \
|
||||
echo "Windows binary not found."; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
# Setup langfuse server
|
||||
langfuse-server:
|
||||
#!/usr/bin/env bash
|
||||
|
||||
@@ -46,6 +46,10 @@ tracing = "0.1"
|
||||
chrono = "0.4"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json", "time"] }
|
||||
tracing-appender = "0.2"
|
||||
winapi = { version = "0.3", features = ["wincred"], optional = true }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winapi = { version = "0.3", features = ["wincred"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -62,12 +62,29 @@ pub async fn handle_configure() -> Result<(), Box<dyn Error>> {
|
||||
);
|
||||
}
|
||||
Some(ConfigError::KeyringError(msg)) => {
|
||||
#[cfg(target_os = "macos")]
|
||||
println!(
|
||||
"\n {} Failed to access secure storage (keyring): {} \n Please check your system keychain and run '{}' again. \n If your system is unable to use the keyring, please try setting secret key(s) via environment variables.",
|
||||
style("Error").red().italic(),
|
||||
msg,
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
println!(
|
||||
"\n {} Failed to access Windows Credential Manager: {} \n Please check Windows Credential Manager and run '{}' again. \n If your system is unable to use the Credential Manager, please try setting secret key(s) via environment variables.",
|
||||
style("Error").red().italic(),
|
||||
msg,
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
println!(
|
||||
"\n {} Failed to access secure storage: {} \n Please check your system's secure storage and run '{}' again. \n If your system is unable to use secure storage, please try setting secret key(s) via environment variables.",
|
||||
style("Error").red().italic(),
|
||||
msg,
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
}
|
||||
Some(ConfigError::DeserializeError(msg)) => {
|
||||
println!(
|
||||
|
||||
@@ -12,7 +12,12 @@ use goose::tracing::langfuse_layer;
|
||||
/// Returns the directory where log files should be stored.
|
||||
/// Creates the directory structure if it doesn't exist.
|
||||
fn get_log_directory() -> Result<PathBuf> {
|
||||
let home = std::env::var("HOME").context("HOME environment variable not set")?;
|
||||
let home = if cfg!(windows) {
|
||||
std::env::var("USERPROFILE").context("USERPROFILE environment variable not set")?
|
||||
} else {
|
||||
std::env::var("HOME").context("HOME environment variable not set")?
|
||||
};
|
||||
|
||||
let base_log_dir = PathBuf::from(home)
|
||||
.join(".config")
|
||||
.join("goose")
|
||||
@@ -114,7 +119,11 @@ mod tests {
|
||||
|
||||
fn setup_temp_home() -> TempDir {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
env::set_var("HOME", temp_dir.path());
|
||||
if cfg!(windows) {
|
||||
env::set_var("USERPROFILE", temp_dir.path());
|
||||
} else {
|
||||
env::set_var("HOME", temp_dir.path());
|
||||
}
|
||||
temp_dir
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@ use indoc::{formatdoc, indoc};
|
||||
use reqwest::{Client, Url};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
collections::HashMap, fs, future::Future, os::unix::fs::PermissionsExt, path::PathBuf,
|
||||
pin::Pin, sync::Arc, sync::Mutex,
|
||||
collections::HashMap, fs, future::Future, path::PathBuf, pin::Pin, sync::Arc, sync::Mutex,
|
||||
};
|
||||
use tokio::process::Command;
|
||||
|
||||
@@ -18,6 +17,9 @@ use mcp_core::{
|
||||
use mcp_server::router::CapabilitiesBuilder;
|
||||
use mcp_server::Router;
|
||||
|
||||
mod platform;
|
||||
use platform::{create_system_automation, SystemAutomation};
|
||||
|
||||
/// An extension designed for non-developers to help them with common tasks like
|
||||
/// web scraping, data processing, and automation.
|
||||
#[derive(Clone)]
|
||||
@@ -27,6 +29,7 @@ pub struct ComputerControllerRouter {
|
||||
active_resources: Arc<Mutex<HashMap<String, Resource>>>,
|
||||
http_client: Client,
|
||||
instructions: String,
|
||||
system_automation: Arc<Box<dyn SystemAutomation + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl Default for ComputerControllerRouter {
|
||||
@@ -86,9 +89,19 @@ impl ComputerControllerRouter {
|
||||
}),
|
||||
);
|
||||
|
||||
let computer_control_tool = Tool::new(
|
||||
"computer_control",
|
||||
indoc! {r#"
|
||||
let computer_control_desc = match std::env::consts::OS {
|
||||
"windows" => indoc! {r#"
|
||||
Control the computer using Windows system automation.
|
||||
|
||||
Features available:
|
||||
- PowerShell automation for system control
|
||||
- UI automation through PowerShell
|
||||
- File and system management
|
||||
- Windows-specific features and settings
|
||||
|
||||
Can be combined with screenshot tool for visual task assistance.
|
||||
"#},
|
||||
_ => indoc! {r#"
|
||||
Control the computer using AppleScript (macOS only). Automate applications and system features.
|
||||
|
||||
Key capabilities:
|
||||
@@ -104,14 +117,19 @@ impl ComputerControllerRouter {
|
||||
- Data: Interact with spreadsheets and documents
|
||||
|
||||
Can be combined with screenshot tool for visual task assistance.
|
||||
"#},
|
||||
"#},
|
||||
};
|
||||
|
||||
let computer_control_tool = Tool::new(
|
||||
"computer_control",
|
||||
computer_control_desc.to_string(),
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["script"],
|
||||
"properties": {
|
||||
"script": {
|
||||
"type": "string",
|
||||
"description": "The AppleScript content to execute"
|
||||
"description": "The automation script content (PowerShell for Windows, AppleScript for macOS)"
|
||||
},
|
||||
"save_output": {
|
||||
"type": "boolean",
|
||||
@@ -122,9 +140,18 @@ impl ComputerControllerRouter {
|
||||
}),
|
||||
);
|
||||
|
||||
let quick_script_tool = Tool::new(
|
||||
"automation_script",
|
||||
indoc! {r#"
|
||||
let quick_script_desc = match std::env::consts::OS {
|
||||
"windows" => indoc! {r#"
|
||||
Create and run small PowerShell or Batch scripts for automation tasks.
|
||||
PowerShell is recommended for most tasks.
|
||||
|
||||
The script is saved to a temporary file and executed.
|
||||
Some examples:
|
||||
- Sort unique lines: Get-Content file.txt | Sort-Object -Unique
|
||||
- Extract CSV column: Import-Csv file.csv | Select-Object -ExpandProperty Column2
|
||||
- Find text: Select-String -Pattern "pattern" -Path file.txt
|
||||
"#},
|
||||
_ => indoc! {r#"
|
||||
Create and run small scripts for automation tasks.
|
||||
Supports Shell and Ruby (on macOS).
|
||||
|
||||
@@ -135,14 +162,19 @@ impl ComputerControllerRouter {
|
||||
- create a sorted list of unique lines: sort file.txt | uniq
|
||||
- extract 2nd column in csv: awk -F "," '{ print $2}'
|
||||
- pattern matching: grep pattern file.txt
|
||||
"#},
|
||||
"#},
|
||||
};
|
||||
|
||||
let quick_script_tool = Tool::new(
|
||||
"automation_script",
|
||||
quick_script_desc.to_string(),
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["language", "script"],
|
||||
"properties": {
|
||||
"language": {
|
||||
"type": "string",
|
||||
"enum": ["shell", "ruby"],
|
||||
"enum": ["shell", "ruby", "powershell", "batch"],
|
||||
"description": "The scripting language to use"
|
||||
},
|
||||
"script": {
|
||||
@@ -186,9 +218,10 @@ impl ComputerControllerRouter {
|
||||
|
||||
// Create cache directory in user's home directory
|
||||
let cache_dir = dirs::cache_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.unwrap_or_else(|| create_system_automation().get_temp_path())
|
||||
.join("goose")
|
||||
.join("computer_controller");
|
||||
|
||||
fs::create_dir_all(&cache_dir).unwrap_or_else(|_| {
|
||||
println!(
|
||||
"Warning: Failed to create cache directory at {:?}",
|
||||
@@ -196,8 +229,41 @@ impl ComputerControllerRouter {
|
||||
)
|
||||
});
|
||||
|
||||
let macos_browser_instructions = if std::env::consts::OS == "macos" {
|
||||
indoc! {r#"
|
||||
let system_automation: Arc<Box<dyn SystemAutomation + Send + Sync>> =
|
||||
Arc::new(create_system_automation());
|
||||
|
||||
let os_specific_instructions = match std::env::consts::OS {
|
||||
"windows" => indoc! {r#"
|
||||
Here are some extra tools:
|
||||
automation_script
|
||||
- Create and run PowerShell or Batch scripts
|
||||
- PowerShell is recommended for most tasks
|
||||
- Scripts can save their output to files
|
||||
- Windows-specific features:
|
||||
- PowerShell for system automation and UI control
|
||||
- Windows Management Instrumentation (WMI)
|
||||
- Registry access and system settings
|
||||
- Use the screenshot tool if needed to help with tasks
|
||||
|
||||
computer_control
|
||||
- System automation using PowerShell
|
||||
- Consider the screenshot tool to work out what is on screen and what to do to help with the control task.
|
||||
"#},
|
||||
_ => indoc! {r#"
|
||||
Here are some extra tools:
|
||||
automation_script
|
||||
- Create and run Shell and Ruby scripts
|
||||
- Shell (bash) is recommended for most tasks
|
||||
- Scripts can save their output to files
|
||||
- macOS-specific features:
|
||||
- AppleScript for system and UI control
|
||||
- Integration with macOS apps and services
|
||||
- Use the screenshot tool if needed to help with tasks
|
||||
|
||||
computer_control
|
||||
- System automation using AppleScript
|
||||
- Consider the screenshot tool to work out what is on screen and what to do to help with the control task.
|
||||
|
||||
When you need to interact with websites or web applications, consider using the computer_control tool with AppleScript, which can automate Safari or other browsers to:
|
||||
- Open specific URLs
|
||||
- Fill in forms
|
||||
@@ -205,47 +271,25 @@ impl ComputerControllerRouter {
|
||||
- Extract content
|
||||
- Handle web-based workflows
|
||||
This is often more reliable than web scraping for modern web applications.
|
||||
"#}
|
||||
} else {
|
||||
""
|
||||
"#},
|
||||
};
|
||||
|
||||
let instructions = formatdoc! {r#"
|
||||
You are a helpful assistant to a power user who is not a professional developer, but you may use devleopment tools to help assist them.
|
||||
You are a helpful assistant to a power user who is not a professional developer, but you may use development tools to help assist them.
|
||||
The user may not know how to break down tasks, so you will need to ensure that you do, and run things in batches as needed.
|
||||
The ComputerControllerExtension helps you with common tasks like web scraping,
|
||||
data processing, and automation and computer control without requiring programming expertise,
|
||||
supplementing the Developer Extension.
|
||||
data processing, and automation without requiring programming expertise.
|
||||
|
||||
You can use scripting as needed to work with text files of data, such as csvs, json, or text files etc.
|
||||
Using the developer extension is allowed for more sophisticated tasks or instructed to (js or py can be helpful for more complex tasks if tools are available).
|
||||
|
||||
{macos_instructions}
|
||||
|
||||
Accessing web sites, even apis, may be common (you can use bash scripting to do this) without troubling them too much (they won't know what limits are).
|
||||
Try to do your best to find ways to complete a task without too many quesitons or offering options unless it is really unclear, find a way if you can.
|
||||
Accessing web sites, even apis, may be common (you can use scripting to do this) without troubling them too much (they won't know what limits are).
|
||||
Try to do your best to find ways to complete a task without too many questions or offering options unless it is really unclear, find a way if you can.
|
||||
You can also guide them steps if they can help out as you go along.
|
||||
|
||||
There is already a screenshot tool available you can use if needed to see what is on screen.
|
||||
|
||||
Here are some extra tools:
|
||||
automation_script
|
||||
- Create and run simple automation scripts
|
||||
- Supports Shell (such as bash), AppleScript (on macos), Ruby (on macos)
|
||||
- Scripts can save their output to files
|
||||
- on macos, can use applescript to interact with the desktop, eg calendars, notes and more, anything apple script can do for apps that support it:
|
||||
AppleScript is a powerful scripting language designed for automating tasks on macOS such as: Integration with Other Scripts
|
||||
Execute shell scripts, Ruby scripts, or other automation scripts.
|
||||
Combine workflows across scripting languages.
|
||||
Complex Workflows
|
||||
Automate multi-step tasks involving multiple apps or system features.
|
||||
Create scheduled tasks using Calendar or other scheduling apps.
|
||||
|
||||
- use the screenshot tool if needed to help with tasks
|
||||
|
||||
computer_control
|
||||
- Control the computer using AppleScript (macOS only)
|
||||
- Consider the screenshot tool to work out what is on screen and what to do to help with the control task.
|
||||
{os_instructions}
|
||||
|
||||
web_search
|
||||
- Search the web using DuckDuckGo's API for general topics or keywords
|
||||
@@ -262,7 +306,7 @@ impl ComputerControllerRouter {
|
||||
- Cache directory: {cache_dir}
|
||||
- File organization and cleanup
|
||||
"#,
|
||||
macos_instructions = macos_browser_instructions,
|
||||
os_instructions = os_specific_instructions,
|
||||
cache_dir = cache_dir.display()
|
||||
};
|
||||
|
||||
@@ -278,6 +322,7 @@ impl ComputerControllerRouter {
|
||||
active_resources: Arc::new(Mutex::new(HashMap::new())),
|
||||
http_client: Client::builder().user_agent("Goose/1.0").build().unwrap(),
|
||||
instructions: instructions.clone(),
|
||||
system_automation,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,7 +363,7 @@ impl ComputerControllerRouter {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Implement web_scrape tool functionality
|
||||
// Implement web_search tool functionality
|
||||
async fn web_search(&self, params: Value) -> Result<Vec<Content>, ToolError> {
|
||||
let query = params
|
||||
.get("query")
|
||||
@@ -452,22 +497,18 @@ impl ComputerControllerRouter {
|
||||
ToolError::ExecutionError(format!("Failed to create temporary directory: {}", e))
|
||||
})?;
|
||||
|
||||
let (shell, shell_arg) = self.system_automation.get_shell_command();
|
||||
|
||||
let command = match language {
|
||||
"shell" => {
|
||||
let script_path = script_dir.path().join("script.sh");
|
||||
"shell" | "batch" => {
|
||||
let script_path = script_dir.path().join(format!(
|
||||
"script.{}",
|
||||
if cfg!(windows) { "bat" } else { "sh" }
|
||||
));
|
||||
fs::write(&script_path, script).map_err(|e| {
|
||||
ToolError::ExecutionError(format!("Failed to write script: {}", e))
|
||||
})?;
|
||||
|
||||
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).map_err(
|
||||
|e| {
|
||||
ToolError::ExecutionError(format!(
|
||||
"Failed to set script permissions: {}",
|
||||
e
|
||||
))
|
||||
},
|
||||
)?;
|
||||
|
||||
script_path.display().to_string()
|
||||
}
|
||||
"ruby" => {
|
||||
@@ -478,12 +519,23 @@ impl ComputerControllerRouter {
|
||||
|
||||
format!("ruby {}", script_path.display())
|
||||
}
|
||||
"powershell" => {
|
||||
let script_path = script_dir.path().join("script.ps1");
|
||||
fs::write(&script_path, script).map_err(|e| {
|
||||
ToolError::ExecutionError(format!("Failed to write script: {}", e))
|
||||
})?;
|
||||
|
||||
format!(
|
||||
"powershell -NoProfile -NonInteractive -File {}",
|
||||
script_path.display()
|
||||
)
|
||||
}
|
||||
_ => unreachable!(), // Prevented by enum in tool definition
|
||||
};
|
||||
|
||||
// Run the script
|
||||
let output = Command::new("bash")
|
||||
.arg("-c")
|
||||
let output = Command::new(shell)
|
||||
.arg(shell_arg)
|
||||
.arg(&command)
|
||||
.output()
|
||||
.await
|
||||
@@ -515,14 +567,8 @@ impl ComputerControllerRouter {
|
||||
Ok(vec![Content::text(result)])
|
||||
}
|
||||
|
||||
// Implement computer control (AppleScript) functionality
|
||||
// Implement computer control functionality
|
||||
async fn computer_control(&self, params: Value) -> Result<Vec<Content>, ToolError> {
|
||||
if std::env::consts::OS != "macos" {
|
||||
return Err(ToolError::ExecutionError(
|
||||
"Computer control (AppleScript) is only supported on macOS".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let script = params
|
||||
.get("script")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -533,44 +579,18 @@ impl ComputerControllerRouter {
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Create a temporary directory for the script
|
||||
let script_dir = tempfile::tempdir().map_err(|e| {
|
||||
ToolError::ExecutionError(format!("Failed to create temporary directory: {}", e))
|
||||
})?;
|
||||
// Use platform-specific automation
|
||||
let output = self
|
||||
.system_automation
|
||||
.execute_system_script(script)
|
||||
.map_err(|e| ToolError::ExecutionError(format!("Failed to execute script: {}", e)))?;
|
||||
|
||||
let script_path = script_dir.path().join("script.scpt");
|
||||
fs::write(&script_path, script)
|
||||
.map_err(|e| ToolError::ExecutionError(format!("Failed to write script: {}", e)))?;
|
||||
|
||||
let command = format!("osascript {}", script_path.display());
|
||||
|
||||
// Run the script
|
||||
let output = Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg(&command)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionError(format!("Failed to run AppleScript: {}", e)))?;
|
||||
|
||||
let output_str = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
let error_str = String::from_utf8_lossy(&output.stderr).into_owned();
|
||||
|
||||
let mut result = if output.status.success() {
|
||||
format!(
|
||||
"AppleScript completed successfully.\n\nOutput:\n{}",
|
||||
output_str
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"AppleScript failed with error code {}.\n\nError:\n{}\nOutput:\n{}",
|
||||
output.status, error_str, output_str
|
||||
)
|
||||
};
|
||||
let mut result = format!("Script completed successfully.\n\nOutput:\n{}", output);
|
||||
|
||||
// Save output if requested
|
||||
if save_output && !output_str.is_empty() {
|
||||
if save_output && !output.is_empty() {
|
||||
let cache_path = self
|
||||
.save_to_cache(output_str.as_bytes(), "applescript_output", "txt")
|
||||
.save_to_cache(output.as_bytes(), "automation_output", "txt")
|
||||
.await?;
|
||||
result.push_str(&format!("\n\nOutput saved to: {}", cache_path.display()));
|
||||
|
||||
|
||||
25
crates/goose-mcp/src/computercontroller/platform/macos.rs
Normal file
25
crates/goose-mcp/src/computercontroller/platform/macos.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use super::SystemAutomation;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
pub struct MacOSAutomation;
|
||||
|
||||
// MacOSAutomation is Send + Sync because it contains no shared state
|
||||
unsafe impl Send for MacOSAutomation {}
|
||||
unsafe impl Sync for MacOSAutomation {}
|
||||
|
||||
impl SystemAutomation for MacOSAutomation {
|
||||
fn execute_system_script(&self, script: &str) -> std::io::Result<String> {
|
||||
let output = Command::new("osascript").arg("-e").arg(script).output()?;
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||
}
|
||||
|
||||
fn get_shell_command(&self) -> (&'static str, &'static str) {
|
||||
("bash", "-c")
|
||||
}
|
||||
|
||||
fn get_temp_path(&self) -> PathBuf {
|
||||
PathBuf::from("/tmp")
|
||||
}
|
||||
}
|
||||
29
crates/goose-mcp/src/computercontroller/platform/mod.rs
Normal file
29
crates/goose-mcp/src/computercontroller/platform/mod.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
mod macos;
|
||||
mod windows;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use self::windows::WindowsAutomation;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use self::macos::MacOSAutomation;
|
||||
|
||||
pub trait SystemAutomation: Send + Sync {
|
||||
fn execute_system_script(&self, script: &str) -> std::io::Result<String>;
|
||||
fn get_shell_command(&self) -> (&'static str, &'static str); // (shell, arg)
|
||||
fn get_temp_path(&self) -> std::path::PathBuf;
|
||||
}
|
||||
|
||||
pub fn create_system_automation() -> Box<dyn SystemAutomation + Send + Sync> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Box::new(WindowsAutomation)
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Box::new(MacOSAutomation)
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
unimplemented!("Unsupported operating system")
|
||||
}
|
||||
}
|
||||
32
crates/goose-mcp/src/computercontroller/platform/windows.rs
Normal file
32
crates/goose-mcp/src/computercontroller/platform/windows.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use super::SystemAutomation;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
pub struct WindowsAutomation;
|
||||
|
||||
// WindowsAutomation is Send + Sync because it contains no shared state
|
||||
unsafe impl Send for WindowsAutomation {}
|
||||
unsafe impl Sync for WindowsAutomation {}
|
||||
|
||||
impl SystemAutomation for WindowsAutomation {
|
||||
fn execute_system_script(&self, script: &str) -> std::io::Result<String> {
|
||||
let output = Command::new("powershell")
|
||||
.arg("-NoProfile")
|
||||
.arg("-NonInteractive")
|
||||
.arg("-Command")
|
||||
.arg(script)
|
||||
.output()?;
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||
}
|
||||
|
||||
fn get_shell_command(&self) -> (&'static str, &'static str) {
|
||||
("powershell", "-Command")
|
||||
}
|
||||
|
||||
fn get_temp_path(&self) -> PathBuf {
|
||||
std::env::var("TEMP")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from(r"C:\Windows\Temp"))
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ pub fn get_language_identifier(path: &Path) -> &'static str {
|
||||
Some("toml") => "toml",
|
||||
Some("yaml") | Some("yml") => "yaml",
|
||||
Some("sh") => "bash",
|
||||
Some("ps1") => "powershell",
|
||||
Some("bat") | Some("cmd") => "batch",
|
||||
Some("vbs") => "vbscript",
|
||||
Some("go") => "go",
|
||||
Some("md") => "markdown",
|
||||
Some("html") => "html",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod lang;
|
||||
mod shell;
|
||||
|
||||
use anyhow::Result;
|
||||
use base64::Engine;
|
||||
@@ -31,6 +32,11 @@ use std::process::Stdio;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use xcap::{Monitor, Window};
|
||||
|
||||
use self::shell::{
|
||||
expand_path, format_command_for_platform, get_shell_config, is_absolute_path,
|
||||
normalize_line_endings,
|
||||
};
|
||||
|
||||
pub struct DeveloperRouter {
|
||||
tools: Vec<Tool>,
|
||||
file_history: Arc<Mutex<HashMap<PathBuf, Vec<String>>>>,
|
||||
@@ -48,9 +54,30 @@ impl DeveloperRouter {
|
||||
// TODO consider rust native search tools, we could use
|
||||
// https://docs.rs/ignore/latest/ignore/
|
||||
|
||||
let bash_tool = Tool::new(
|
||||
"shell".to_string(),
|
||||
indoc! {r#"
|
||||
// Get OS-specific shell tool description
|
||||
let shell_tool_desc = match std::env::consts::OS {
|
||||
"windows" => indoc! {r#"
|
||||
Execute a command in the shell.
|
||||
|
||||
This will return the output and error concatenated into a single string, as
|
||||
you would see from running on the command line. There will also be an indication
|
||||
of if the command succeeded or failed.
|
||||
|
||||
Avoid commands that produce a large amount of output, and consider piping those outputs to files.
|
||||
|
||||
**Important**: For searching files and code:
|
||||
|
||||
Preferred: Use ripgrep (`rg`) when available - it respects .gitignore and is fast:
|
||||
- To locate a file by name: `rg --files | rg example.py`
|
||||
- To locate content inside files: `rg 'class Example'`
|
||||
|
||||
Alternative Windows commands (if ripgrep is not installed):
|
||||
- To locate a file by name: `dir /s /b example.py`
|
||||
- To locate content inside files: `findstr /s /i "class Example" *.py`
|
||||
|
||||
Note: Alternative commands may show ignored/hidden files that should be excluded.
|
||||
"#},
|
||||
_ => indoc! {r#"
|
||||
Execute a command in the shell.
|
||||
|
||||
This will return the output and error concatenated into a single string, as
|
||||
@@ -64,8 +91,13 @@ impl DeveloperRouter {
|
||||
**Important**: Use ripgrep - `rg` - when you need to locate a file or a code reference, other solutions
|
||||
may show ignored or hidden files. For example *do not* use `find` or `ls -r`
|
||||
- To locate a file by name: `rg --files | rg example.py`
|
||||
- To locate consent inside files: `rg 'class Example'`
|
||||
"#}.to_string(),
|
||||
- To locate content inside files: `rg 'class Example'`
|
||||
"#},
|
||||
};
|
||||
|
||||
let bash_tool = Tool::new(
|
||||
"shell".to_string(),
|
||||
shell_tool_desc.to_string(),
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["command"],
|
||||
@@ -157,9 +189,31 @@ impl DeveloperRouter {
|
||||
|
||||
// Get base instructions and working directory
|
||||
let cwd = std::env::current_dir().expect("should have a current working dir");
|
||||
let base_instructions = formatdoc! {r#"
|
||||
The developer extension gives you the capabilities to edit code files and run shell commands,
|
||||
and can be used to solve a wide range of problems.
|
||||
let os = std::env::consts::OS;
|
||||
|
||||
let base_instructions = match os {
|
||||
"windows" => formatdoc! {r#"
|
||||
The developer extension gives you the capabilities to edit code files and run shell commands,
|
||||
and can be used to solve a wide range of problems.
|
||||
|
||||
You can use the shell tool to run Windows commands (PowerShell or CMD).
|
||||
When using paths, you can use either backslashes or forward slashes.
|
||||
|
||||
Use the shell tool as needed to locate files or interact with the project.
|
||||
|
||||
Your windows/screen tools can be used for visual debugging. You should not use these tools unless
|
||||
prompted to, but you can mention they are available if they are relevant.
|
||||
|
||||
operating system: {os}
|
||||
current directory: {cwd}
|
||||
|
||||
"#,
|
||||
os=os,
|
||||
cwd=cwd.to_string_lossy(),
|
||||
},
|
||||
_ => formatdoc! {r#"
|
||||
The developer extension gives you the capabilities to edit code files and run shell commands,
|
||||
and can be used to solve a wide range of problems.
|
||||
|
||||
You can use the shell tool to run any command that would work on the relevant operating system.
|
||||
Use the shell tool as needed to locate files or interact with the project.
|
||||
@@ -170,9 +224,10 @@ impl DeveloperRouter {
|
||||
operating system: {os}
|
||||
current directory: {cwd}
|
||||
|
||||
"#,
|
||||
os=std::env::consts::OS,
|
||||
cwd=cwd.to_string_lossy(),
|
||||
"#,
|
||||
os=os,
|
||||
cwd=cwd.to_string_lossy(),
|
||||
},
|
||||
};
|
||||
|
||||
// Check for global hints in ~/.config/goose/.goosehints
|
||||
@@ -223,15 +278,15 @@ impl DeveloperRouter {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to resolve a path relative to cwd
|
||||
// Helper method to resolve a path relative to cwd with platform-specific handling
|
||||
fn resolve_path(&self, path_str: &str) -> Result<PathBuf, ToolError> {
|
||||
let cwd = std::env::current_dir().expect("should have a current working dir");
|
||||
let expanded = shellexpand::tilde(path_str);
|
||||
let path = Path::new(expanded.as_ref());
|
||||
let expanded = expand_path(path_str);
|
||||
let path = Path::new(&expanded);
|
||||
|
||||
let suggestion = cwd.join(path);
|
||||
|
||||
match path.is_absolute() {
|
||||
match is_absolute_path(&expanded) {
|
||||
true => Ok(path.to_path_buf()),
|
||||
false => Err(ToolError::InvalidParameters(format!(
|
||||
"The path {} is not an absolute path, did you possibly mean {}?",
|
||||
@@ -241,7 +296,7 @@ impl DeveloperRouter {
|
||||
}
|
||||
}
|
||||
|
||||
// Implement bash tool functionality
|
||||
// Shell command execution with platform-specific handling
|
||||
async fn bash(&self, params: Value) -> Result<Vec<Content>, ToolError> {
|
||||
let command =
|
||||
params
|
||||
@@ -251,19 +306,17 @@ impl DeveloperRouter {
|
||||
"The command string is required".to_string(),
|
||||
))?;
|
||||
|
||||
// TODO consider command suggestions and safety rails
|
||||
// Get platform-specific shell configuration
|
||||
let shell_config = get_shell_config();
|
||||
let cmd_with_redirect = format_command_for_platform(command);
|
||||
|
||||
// TODO be more careful about backgrounding, revisit interleave
|
||||
// Redirect stderr to stdout to interleave outputs
|
||||
let cmd_with_redirect = format!("{} 2>&1", command);
|
||||
|
||||
// Execute the command
|
||||
let child = Command::new("bash")
|
||||
.stdout(Stdio::piped()) // These two pipes required to capture output later.
|
||||
// Execute the command using platform-specific shell
|
||||
let child = Command::new(&shell_config.executable)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.stdin(Stdio::null())
|
||||
.kill_on_drop(true) // Critical so that the command is killed when the agent.reply stream is interrupted.
|
||||
.arg("-c")
|
||||
.kill_on_drop(true)
|
||||
.arg(&shell_config.arg)
|
||||
.arg(cmd_with_redirect)
|
||||
.spawn()
|
||||
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
|
||||
@@ -417,12 +470,15 @@ impl DeveloperRouter {
|
||||
path: &PathBuf,
|
||||
file_text: &str,
|
||||
) -> Result<Vec<Content>, ToolError> {
|
||||
// Normalize line endings based on platform
|
||||
let normalized_text = normalize_line_endings(file_text);
|
||||
|
||||
// Write to the file
|
||||
std::fs::write(path, file_text)
|
||||
std::fs::write(path, normalized_text)
|
||||
.map_err(|e| ToolError::ExecutionError(format!("Failed to write file: {}", e)))?;
|
||||
|
||||
// Try to detect the language from the file extension
|
||||
let language = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
|
||||
let language = lang::get_language_identifier(path);
|
||||
|
||||
// The assistant output does not show the file again because the content is already in the tool request
|
||||
// but we do show it to the user here
|
||||
@@ -478,13 +534,14 @@ impl DeveloperRouter {
|
||||
// Save history for undo
|
||||
self.save_file_history(path)?;
|
||||
|
||||
// Replace and write back
|
||||
// Replace and write back with platform-specific line endings
|
||||
let new_content = content.replace(old_str, new_str);
|
||||
std::fs::write(path, &new_content)
|
||||
let normalized_content = normalize_line_endings(&new_content);
|
||||
std::fs::write(path, &normalized_content)
|
||||
.map_err(|e| ToolError::ExecutionError(format!("Failed to write file: {}", e)))?;
|
||||
|
||||
// Try to detect the language from the file extension
|
||||
let language = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
|
||||
let language = lang::get_language_identifier(path);
|
||||
|
||||
// Show a snippet of the changed content with context
|
||||
const SNIPPET_LINES: usize = 4;
|
||||
@@ -811,65 +868,28 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_text_editor_size_limits() {
|
||||
// Create temp directory first so it stays in scope for the whole test
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
std::env::set_current_dir(&temp_dir).unwrap();
|
||||
|
||||
// Get router after setting current directory
|
||||
#[cfg(windows)]
|
||||
async fn test_windows_specific_commands() {
|
||||
let router = get_router().await;
|
||||
|
||||
// Test file size limit
|
||||
{
|
||||
let large_file_path = temp_dir.path().join("large.txt");
|
||||
let large_file_str = large_file_path.to_str().unwrap();
|
||||
// Test PowerShell command
|
||||
let result = router
|
||||
.call_tool(
|
||||
"shell",
|
||||
json!({
|
||||
"command": "Get-ChildItem"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Create a file larger than 2MB
|
||||
let content = "x".repeat(3 * 1024 * 1024); // 3MB
|
||||
std::fs::write(&large_file_path, content).unwrap();
|
||||
// Test Windows path handling
|
||||
let result = router.resolve_path("C:\\Windows\\System32");
|
||||
assert!(result.is_ok());
|
||||
|
||||
let result = router
|
||||
.call_tool(
|
||||
"text_editor",
|
||||
json!({
|
||||
"command": "view",
|
||||
"path": large_file_str
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.err().unwrap();
|
||||
assert!(matches!(err, ToolError::ExecutionError(_)));
|
||||
assert!(err.to_string().contains("too large"));
|
||||
}
|
||||
|
||||
// Test character count limit
|
||||
{
|
||||
let many_chars_path = temp_dir.path().join("many_chars.txt");
|
||||
let many_chars_str = many_chars_path.to_str().unwrap();
|
||||
|
||||
// Create a file with more than 400K characters but less than 400KB
|
||||
let content = "x".repeat(405_000);
|
||||
std::fs::write(&many_chars_path, content).unwrap();
|
||||
|
||||
let result = router
|
||||
.call_tool(
|
||||
"text_editor",
|
||||
json!({
|
||||
"command": "view",
|
||||
"path": many_chars_str
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.err().unwrap();
|
||||
assert!(matches!(err, ToolError::ExecutionError(_)));
|
||||
assert!(err.to_string().contains("too many characters"));
|
||||
}
|
||||
|
||||
// Let temp_dir drop naturally at end of scope
|
||||
// Test UNC path handling
|
||||
let result = router.resolve_path("\\\\server\\share");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
72
crates/goose-mcp/src/developer/shell.rs
Normal file
72
crates/goose-mcp/src/developer/shell.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use std::env;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShellConfig {
|
||||
pub executable: String,
|
||||
pub arg: String,
|
||||
pub redirect_syntax: String,
|
||||
}
|
||||
|
||||
impl Default for ShellConfig {
|
||||
fn default() -> Self {
|
||||
if cfg!(windows) {
|
||||
// Use cmd.exe for simpler command execution
|
||||
Self {
|
||||
executable: "cmd.exe".to_string(),
|
||||
arg: "/C".to_string(),
|
||||
redirect_syntax: "2>&1".to_string(), // cmd.exe also supports this syntax
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
executable: "bash".to_string(),
|
||||
arg: "-c".to_string(),
|
||||
redirect_syntax: "2>&1".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_shell_config() -> ShellConfig {
|
||||
ShellConfig::default()
|
||||
}
|
||||
|
||||
pub fn format_command_for_platform(command: &str) -> String {
|
||||
let config = get_shell_config();
|
||||
// For all shells, no braces needed
|
||||
format!("{} {}", command, config.redirect_syntax)
|
||||
}
|
||||
|
||||
pub fn expand_path(path_str: &str) -> String {
|
||||
if cfg!(windows) {
|
||||
// Expand Windows environment variables (%VAR%)
|
||||
let with_userprofile = path_str.replace(
|
||||
"%USERPROFILE%",
|
||||
&env::var("USERPROFILE").unwrap_or_default(),
|
||||
);
|
||||
// Add more Windows environment variables as needed
|
||||
with_userprofile.replace("%APPDATA%", &env::var("APPDATA").unwrap_or_default())
|
||||
} else {
|
||||
// Unix-style expansion
|
||||
shellexpand::tilde(path_str).into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_absolute_path(path_str: &str) -> bool {
|
||||
if cfg!(windows) {
|
||||
// Check for Windows absolute paths (drive letters and UNC)
|
||||
path_str.contains(":\\") || path_str.starts_with("\\\\")
|
||||
} else {
|
||||
// Unix absolute paths start with /
|
||||
path_str.starts_with('/')
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_line_endings(text: &str) -> String {
|
||||
if cfg!(windows) {
|
||||
// Ensure CRLF line endings on Windows
|
||||
text.replace("\r\n", "\n").replace("\n", "\r\n")
|
||||
} else {
|
||||
// Ensure LF line endings on Unix
|
||||
text.replace("\r\n", "\n")
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,12 @@ use goose::tracing::langfuse_layer;
|
||||
/// Returns the directory where log files should be stored.
|
||||
/// Creates the directory structure if it doesn't exist.
|
||||
fn get_log_directory() -> Result<PathBuf> {
|
||||
let home = std::env::var("HOME").context("HOME environment variable not set")?;
|
||||
let home = if cfg!(windows) {
|
||||
std::env::var("USERPROFILE").context("USERPROFILE environment variable not set")?
|
||||
} else {
|
||||
std::env::var("HOME").context("HOME environment variable not set")?
|
||||
};
|
||||
|
||||
let base_log_dir = PathBuf::from(home)
|
||||
.join(".config")
|
||||
.join("goose")
|
||||
|
||||
@@ -66,6 +66,9 @@ aws-config = { version = "1.1.7", features = ["behavior-version-latest"] }
|
||||
aws-smithy-types = "1.2.12"
|
||||
aws-sdk-bedrockruntime = "1.72.0"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winapi = { version = "0.3", features = ["wincred"] }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = "0.5"
|
||||
tempfile = "3.15.0"
|
||||
|
||||
@@ -32,7 +32,11 @@ struct TokenCache {
|
||||
|
||||
fn get_base_path() -> PathBuf {
|
||||
const BASE_PATH: &str = ".config/goose/databricks/oauth";
|
||||
let home_dir = std::env::var("HOME").expect("HOME environment variable not set");
|
||||
let home_dir = if cfg!(windows) {
|
||||
std::env::var("USERPROFILE").expect("USERPROFILE environment variable not set")
|
||||
} else {
|
||||
std::env::var("HOME").expect("HOME environment variable not set")
|
||||
};
|
||||
PathBuf::from(home_dir).join(BASE_PATH)
|
||||
}
|
||||
|
||||
|
||||
@@ -196,15 +196,24 @@ impl StdioTransport {
|
||||
}
|
||||
|
||||
async fn spawn_process(&self) -> Result<(Child, ChildStdin, ChildStdout, ChildStderr), Error> {
|
||||
let mut process = Command::new(&self.command)
|
||||
let mut command = Command::new(&self.command);
|
||||
command
|
||||
.envs(&self.env)
|
||||
.args(&self.args)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
// 0 sets the process group ID equal to the process ID
|
||||
.process_group(0) // don't inherit signal handling from parent process
|
||||
.kill_on_drop(true);
|
||||
|
||||
// Set process group only on Unix systems
|
||||
#[cfg(unix)]
|
||||
command.process_group(0); // don't inherit signal handling from parent process
|
||||
|
||||
// Hide console window on Windows
|
||||
#[cfg(windows)]
|
||||
command.creation_flags(0x08000000); // CREATE_NO_WINDOW flag
|
||||
|
||||
let mut process = command
|
||||
.spawn()
|
||||
.map_err(|e| Error::StdioProcessError(e.to_string()))?;
|
||||
|
||||
|
||||
1
ui/desktop/.gitignore
vendored
1
ui/desktop/.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules
|
||||
.vite/
|
||||
out
|
||||
src/bin/goosed
|
||||
/src/bin/goosed.exe
|
||||
|
||||
@@ -5,6 +5,22 @@ let cfg = {
|
||||
asar: true,
|
||||
extraResource: ['src/bin', 'src/images'],
|
||||
icon: 'src/images/icon',
|
||||
// Windows specific configuration
|
||||
win32: {
|
||||
icon: 'src/images/icon.ico',
|
||||
certificateFile: process.env.WINDOWS_CERTIFICATE_FILE,
|
||||
certificatePassword: process.env.WINDOWS_CERTIFICATE_PASSWORD,
|
||||
rfc3161TimeStampServer: 'http://timestamp.digicert.com',
|
||||
signWithParams: '/fd sha256 /tr http://timestamp.digicert.com /td sha256'
|
||||
},
|
||||
// Protocol registration
|
||||
protocols: [
|
||||
{
|
||||
name: "GooseProtocol",
|
||||
schemes: ["goose"]
|
||||
}
|
||||
],
|
||||
// macOS specific configuration
|
||||
osxSign: {
|
||||
entitlements: 'entitlements.plist',
|
||||
'entitlements-inherit': 'entitlements.plist',
|
||||
@@ -34,13 +50,14 @@ module.exports = {
|
||||
packagerConfig: cfg,
|
||||
rebuildConfig: {},
|
||||
makers: [
|
||||
{
|
||||
name: '@electron-forge/maker-squirrel',
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-zip',
|
||||
platforms: ['darwin'],
|
||||
platforms: ['darwin', 'win32'],
|
||||
config: {
|
||||
options: {
|
||||
icon: 'src/images/icon.ico'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-deb',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "goose-app",
|
||||
"productName": "Goose",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.51",
|
||||
"description": "Goose App",
|
||||
"main": ".vite/build/main.js",
|
||||
"scripts": {
|
||||
@@ -11,6 +11,7 @@
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make",
|
||||
"bundle:default": "npm run make && cd out/Goose-darwin-arm64 && ditto -c -k --sequesterRsrc --keepParent Goose.app Goose.zip",
|
||||
"bundle:windows": "npm run make -- --platform=win32 --arch=x64 && node scripts/copy-windows-dlls.js",
|
||||
"debug": "echo 'run --remote-debugging-port=8315' && lldb out/Goose-darwin-arm64/Goose.app",
|
||||
"test-e2e": "electron-forge start > /tmp/out.txt & ELECTRON_PID=$! && sleep 12 && if grep -q 'renderer: ChatWindow loaded' /tmp/out.txt; then echo 'process is running'; pkill -f electron; else echo 'not starting correctly'; cat /tmp/out.txt; pkill -f electron; exit 1; fi",
|
||||
"lint": "eslint \"src/**/*.{ts,tsx}\" --fix",
|
||||
|
||||
84
ui/desktop/scripts/copy-windows-dlls.js
Normal file
84
ui/desktop/scripts/copy-windows-dlls.js
Normal file
@@ -0,0 +1,84 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// Required DLLs that must be present
|
||||
const REQUIRED_DLLS = [
|
||||
'libstdc++-6.dll',
|
||||
'libgcc_s_seh-1.dll',
|
||||
'libwinpthread-1.dll'
|
||||
];
|
||||
|
||||
// Source and target directories
|
||||
const sourceDir = path.join(__dirname, '../src/bin');
|
||||
const targetDir = path.join(__dirname, '../out/Goose-win32-x64/resources/bin');
|
||||
|
||||
function ensureDirectoryExists(dir) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
console.log(`Created directory: ${dir}`);
|
||||
}
|
||||
}
|
||||
|
||||
function copyDLLs() {
|
||||
// Ensure target directory exists
|
||||
ensureDirectoryExists(targetDir);
|
||||
|
||||
// Get list of DLLs in source directory
|
||||
const sourceDLLs = fs.readdirSync(sourceDir)
|
||||
.filter(file => file.toLowerCase().endsWith('.dll'));
|
||||
|
||||
console.log('Found DLLs in source directory:', sourceDLLs);
|
||||
|
||||
// Check for missing required DLLs
|
||||
const missingDLLs = REQUIRED_DLLS.filter(dll =>
|
||||
!sourceDLLs.includes(dll)
|
||||
);
|
||||
|
||||
if (missingDLLs.length > 0) {
|
||||
console.error('Missing required DLLs:', missingDLLs);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Copy all DLLs and the executable to target directory
|
||||
sourceDLLs.forEach(dll => {
|
||||
const sourcePath = path.join(sourceDir, dll);
|
||||
const targetPath = path.join(targetDir, dll);
|
||||
|
||||
try {
|
||||
fs.copyFileSync(sourcePath, targetPath);
|
||||
console.log(`Copied ${dll} to ${targetDir}`);
|
||||
} catch (err) {
|
||||
console.error(`Error copying ${dll}:`, err);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Copy the executable
|
||||
const exeName = 'goosed.exe';
|
||||
const sourceExe = path.join(sourceDir, exeName);
|
||||
const targetExe = path.join(targetDir, exeName);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(sourceExe)) {
|
||||
fs.copyFileSync(sourceExe, targetExe);
|
||||
console.log(`Copied ${exeName} to ${targetDir}`);
|
||||
} else {
|
||||
console.error(`${exeName} not found in source directory`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error copying ${exeName}:`, err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('All files copied successfully');
|
||||
}
|
||||
|
||||
// Main execution
|
||||
try {
|
||||
copyDLLs();
|
||||
} catch (err) {
|
||||
console.error('Error during copy process:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import WingToWing, { Working } from './components/WingToWing';
|
||||
import { askAi } from './utils/askAI';
|
||||
import { getStoredModel, Provider } from './utils/providerUtils';
|
||||
import { ChatLayout } from './components/chat_window/ChatLayout';
|
||||
import { ChatRoutes } from './components/chat_window/ChatRoutes';
|
||||
import { WelcomeScreen } from './components/welcome_screen/WelcomeScreen';
|
||||
import { getStoredProvider, initializeSystem } from './utils/providerUtils';
|
||||
import { useModel } from './components/settings/models/ModelContext';
|
||||
@@ -22,6 +21,9 @@ import { useRecentModels } from './components/settings/models/RecentModels';
|
||||
import { createSelectedModel } from './components/settings/models/utils';
|
||||
import { getDefaultModel } from './components/settings/models/hardcoded_stuff';
|
||||
import Splash from './components/Splash';
|
||||
import Settings from './components/settings/Settings';
|
||||
import MoreModelsSettings from './components/settings/models/MoreModels';
|
||||
import ConfigureProviders from './components/settings/providers/ConfigureProviders';
|
||||
|
||||
export interface Chat {
|
||||
id: number;
|
||||
@@ -33,13 +35,19 @@ export interface Chat {
|
||||
}>;
|
||||
}
|
||||
|
||||
export type View = 'welcome' | 'chat' | 'settings' | 'moreModels' | 'configureProviders';
|
||||
|
||||
// This component is our main chat content.
|
||||
// We'll move the majority of chat logic here, minus the 'view' state.
|
||||
export function ChatContent({
|
||||
chats,
|
||||
setChats,
|
||||
selectedChatId,
|
||||
setSelectedChatId,
|
||||
initialQuery,
|
||||
setProgressMessage,
|
||||
setWorking,
|
||||
setView,
|
||||
}: {
|
||||
chats: Chat[];
|
||||
setChats: React.Dispatch<React.SetStateAction<Chat[]>>;
|
||||
@@ -48,6 +56,7 @@ export function ChatContent({
|
||||
initialQuery: string | null;
|
||||
setProgressMessage: React.Dispatch<React.SetStateAction<string>>;
|
||||
setWorking: React.Dispatch<React.SetStateAction<Working>>;
|
||||
setView: (view: View) => void;
|
||||
}) {
|
||||
const chat = chats.find((c: Chat) => c.id === selectedChatId);
|
||||
const [messageMetadata, setMessageMetadata] = useState<Record<string, string[]>>({});
|
||||
@@ -95,7 +104,6 @@ export function ChatContent({
|
||||
window.electron.logInfo('last interaction:' + lastInteractionTime);
|
||||
if (timeSinceLastInteraction > 60000) {
|
||||
// 60000ms = 1 minute
|
||||
|
||||
window.electron.showNotification({
|
||||
title: 'Goose finished the task.',
|
||||
body: 'Click here to expand.',
|
||||
@@ -133,7 +141,7 @@ export function ChatContent({
|
||||
setLastInteractionTime(Date.now());
|
||||
append({
|
||||
role: 'user',
|
||||
content: content,
|
||||
content,
|
||||
});
|
||||
if (scrollRef.current?.scrollToBottom) {
|
||||
scrollRef.current.scrollToBottom();
|
||||
@@ -194,7 +202,8 @@ export function ChatContent({
|
||||
return (
|
||||
<div className="flex flex-col w-full h-screen items-center justify-center">
|
||||
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle border-b border-borderSubtle">
|
||||
<MoreMenu />
|
||||
{/* Pass setView to MoreMenu so it can switch to settings or other views */}
|
||||
<MoreMenu setView={setView} />
|
||||
</div>
|
||||
<Card className="flex flex-col flex-1 rounded-none h-[calc(100vh-95px)] w-full bg-bgApp mt-0 border-none relative">
|
||||
{messages.length === 0 ? (
|
||||
@@ -215,12 +224,6 @@ export function ChatContent({
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* {isLoading && (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<div onClick={() => setShowGame(true)} style={{ cursor: 'pointer' }}>
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
{error && (
|
||||
<div className="flex flex-col items-center justify-center p-4">
|
||||
<div className="text-red-700 dark:text-red-300 bg-red-400/50 p-3 rounded-lg mb-2">
|
||||
@@ -258,7 +261,7 @@ export function ChatContent({
|
||||
isLoading={isLoading}
|
||||
onStop={onStopGoose}
|
||||
/>
|
||||
<BottomMenu hasMessages={hasMessages} />
|
||||
<BottomMenu hasMessages={hasMessages} setView={setView} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -268,98 +271,59 @@ export function ChatContent({
|
||||
}
|
||||
|
||||
export default function ChatWindow() {
|
||||
// We'll add a state controlling which "view" is active.
|
||||
const [view, setView] = useState<View>('welcome');
|
||||
|
||||
// Shared function to create a chat window
|
||||
const openNewChatWindow = () => {
|
||||
window.electron.createChatWindow();
|
||||
};
|
||||
const { switchModel, currentModel } = useModel(); // Access switchModel via useModel
|
||||
const { addRecentModel } = useRecentModels(); // Access addRecentModel from useRecentModels
|
||||
const { switchModel } = useModel();
|
||||
const { addRecentModel } = useRecentModels();
|
||||
|
||||
// Add keyboard shortcut handler
|
||||
// This will store chat data for the "chat" view.
|
||||
const [chats, setChats] = useState<Chat[]>(() => [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Chat 1',
|
||||
messages: [],
|
||||
},
|
||||
]);
|
||||
const [selectedChatId, setSelectedChatId] = useState(1);
|
||||
|
||||
// Additional states
|
||||
const [mode, setMode] = useState<'expanded' | 'compact'>('expanded');
|
||||
const [working, setWorking] = useState<Working>(Working.Idle);
|
||||
const [progressMessage, setProgressMessage] = useState<string>('');
|
||||
const [initialQuery, setInitialQuery] = useState<string | null>(null);
|
||||
|
||||
// Keyboard shortcut handler
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Check for Command+N (Mac) or Control+N (Windows/Linux)
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'n') {
|
||||
event.preventDefault(); // Prevent default browser behavior
|
||||
event.preventDefault();
|
||||
openNewChatWindow();
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listener
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get initial query and history from URL parameters
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const initialQuery = searchParams.get('initialQuery');
|
||||
const historyParam = searchParams.get('history');
|
||||
const initialHistory = historyParam ? JSON.parse(decodeURIComponent(historyParam)) : [];
|
||||
|
||||
const [chats, setChats] = useState<Chat[]>(() => {
|
||||
const firstChat = {
|
||||
id: 1,
|
||||
title: initialQuery || 'Chat 1',
|
||||
messages: initialHistory.length > 0 ? initialHistory : [],
|
||||
};
|
||||
return [firstChat];
|
||||
});
|
||||
|
||||
const [selectedChatId, setSelectedChatId] = useState(1);
|
||||
const [mode, setMode] = useState<'expanded' | 'compact'>(initialQuery ? 'compact' : 'expanded');
|
||||
const [working, setWorking] = useState<Working>(Working.Idle);
|
||||
const [progressMessage, setProgressMessage] = useState<string>('');
|
||||
const [selectedProvider, setSelectedProvider] = useState<string | Provider | null>(null);
|
||||
const [showWelcomeModal, setShowWelcomeModal] = useState(true);
|
||||
|
||||
// Add this useEffect to track changes and update welcome state
|
||||
const toggleMode = () => {
|
||||
const newMode = mode === 'expanded' ? 'compact' : 'expanded';
|
||||
console.log(`Toggle to ${newMode}`);
|
||||
setMode(newMode);
|
||||
};
|
||||
|
||||
window.electron.logInfo('ChatWindow loaded');
|
||||
|
||||
// Fix the handleSubmit function syntax
|
||||
const handleSubmit = () => {
|
||||
setShowWelcomeModal(false);
|
||||
};
|
||||
|
||||
// Attempt to detect config for a stored provider
|
||||
useEffect(() => {
|
||||
// Check if we already have a provider set
|
||||
const config = window.electron.getConfig();
|
||||
const storedProvider = getStoredProvider(config);
|
||||
|
||||
if (storedProvider) {
|
||||
setShowWelcomeModal(false);
|
||||
setView('chat');
|
||||
} else {
|
||||
setShowWelcomeModal(true);
|
||||
setView('welcome');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const storeSecret = async (key: string, value: string) => {
|
||||
const response = await fetch(getApiUrl('/configs/store'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Secret-Key': getSecretKey(),
|
||||
},
|
||||
body: JSON.stringify({ key, value }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to store secret: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
// Initialize system on load if we have a stored provider
|
||||
// Initialize system if we have a stored provider
|
||||
useEffect(() => {
|
||||
const setupStoredProvider = async () => {
|
||||
const config = window.electron.getConfig();
|
||||
@@ -378,19 +342,10 @@ export default function ChatWindow() {
|
||||
await initializeSystem(storedProvider, storedModel);
|
||||
|
||||
if (!storedModel) {
|
||||
// get the default model
|
||||
const modelName = getDefaultModel(storedProvider.toLowerCase());
|
||||
|
||||
// create model object
|
||||
const model = createSelectedModel(storedProvider.toLowerCase(), modelName);
|
||||
|
||||
// Call the context's switchModel to track the set model state in the front end
|
||||
switchModel(model);
|
||||
|
||||
// Keep track of the recently used models
|
||||
addRecentModel(model);
|
||||
|
||||
console.log('set up provider with default model', storedProvider, modelName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize with stored provider:', error);
|
||||
@@ -401,24 +356,58 @@ export default function ChatWindow() {
|
||||
setupStoredProvider();
|
||||
}, []);
|
||||
|
||||
// Render WelcomeScreen at root level if showing
|
||||
if (showWelcomeModal) {
|
||||
return <WelcomeScreen onSubmit={handleSubmit} />;
|
||||
}
|
||||
// Render everything inside ChatLayout now
|
||||
// We'll switch views inside the ChatLayout children.
|
||||
|
||||
// If we want to skip showing ChatLayout for the welcome screen, we can do so.
|
||||
// But let's do exactly what's requested: put all view options under ChatLayout.
|
||||
|
||||
// Only render ChatLayout if not showing welcome screen
|
||||
return (
|
||||
<div>
|
||||
<ChatLayout mode={mode}>
|
||||
<ChatRoutes
|
||||
<ChatLayout mode={mode}>
|
||||
{/* Conditionally render based on `view` */}
|
||||
{view === 'welcome' && (
|
||||
<WelcomeScreen
|
||||
onSubmit={() => {
|
||||
setView('chat');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{view === 'settings' && (
|
||||
<Settings
|
||||
onClose={() => {
|
||||
setView('chat');
|
||||
}}
|
||||
setView={setView}
|
||||
/>
|
||||
)}
|
||||
{view === 'moreModels' && (
|
||||
<MoreModelsSettings
|
||||
onClose={() => {
|
||||
setView('settings');
|
||||
}}
|
||||
setView={setView}
|
||||
/>
|
||||
)}
|
||||
{view === 'configureProviders' && (
|
||||
<ConfigureProviders
|
||||
onClose={() => {
|
||||
setView('settings');
|
||||
}}
|
||||
setView={setView}
|
||||
/>
|
||||
)}
|
||||
{view === 'chat' && (
|
||||
<ChatContent
|
||||
chats={chats}
|
||||
setChats={setChats}
|
||||
selectedChatId={selectedChatId}
|
||||
setSelectedChatId={setSelectedChatId}
|
||||
initialQuery={initialQuery}
|
||||
setProgressMessage={setProgressMessage}
|
||||
setWorking={setWorking}
|
||||
setView={setView}
|
||||
/>
|
||||
</ChatLayout>
|
||||
</div>
|
||||
)}
|
||||
</ChatLayout>
|
||||
);
|
||||
}
|
||||
|
||||
BIN
ui/desktop/src/bin/libgcc_s_seh-1.dll
Executable file
BIN
ui/desktop/src/bin/libgcc_s_seh-1.dll
Executable file
Binary file not shown.
BIN
ui/desktop/src/bin/libstdc++-6.dll
Executable file
BIN
ui/desktop/src/bin/libstdc++-6.dll
Executable file
Binary file not shown.
BIN
ui/desktop/src/bin/libwinpthread-1.dll
Executable file
BIN
ui/desktop/src/bin/libwinpthread-1.dll
Executable file
Binary file not shown.
20
ui/desktop/src/bin/npx.cmd
Normal file
20
ui/desktop/src/bin/npx.cmd
Normal file
@@ -0,0 +1,20 @@
|
||||
:: Created by npm, please don't edit manually.
|
||||
@ECHO OFF
|
||||
|
||||
SETLOCAL
|
||||
|
||||
SET "NODE_EXE=%~dp0\node.exe"
|
||||
IF NOT EXIST "%NODE_EXE%" (
|
||||
SET "NODE_EXE=node"
|
||||
)
|
||||
|
||||
SET "NPM_PREFIX_JS=%~dp0\node_modules\npm\bin\npm-prefix.js"
|
||||
SET "NPX_CLI_JS=%~dp0\node_modules\npm\bin\npx-cli.js"
|
||||
FOR /F "delims=" %%F IN ('CALL "%NODE_EXE%" "%NPM_PREFIX_JS%"') DO (
|
||||
SET "NPM_PREFIX_NPX_CLI_JS=%%F\node_modules\npm\bin\npx-cli.js"
|
||||
)
|
||||
IF EXIST "%NPM_PREFIX_NPX_CLI_JS%" (
|
||||
SET "NPX_CLI_JS=%NPM_PREFIX_NPX_CLI_JS%"
|
||||
)
|
||||
|
||||
"%NODE_EXE%" "%NPX_CLI_JS%" %*
|
||||
BIN
ui/desktop/src/bin/uv.exe
Normal file
BIN
ui/desktop/src/bin/uv.exe
Normal file
Binary file not shown.
BIN
ui/desktop/src/bin/uvx.exe
Normal file
BIN
ui/desktop/src/bin/uvx.exe
Normal file
Binary file not shown.
@@ -3,14 +3,21 @@ import { useModel } from './settings/models/ModelContext';
|
||||
import { useRecentModels } from './settings/models/RecentModels'; // Hook for recent models
|
||||
import { Sliders } from 'lucide-react';
|
||||
import { ModelRadioList } from './settings/models/ModelRadioList';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
// Remove react-router-dom usage
|
||||
// import { useNavigate } from 'react-router-dom';
|
||||
import { Document, ChevronUp, ChevronDown } from './icons';
|
||||
import type { View } from '../ChatWindow';
|
||||
|
||||
export default function BottomMenu({ hasMessages }) {
|
||||
export default function BottomMenu({
|
||||
hasMessages,
|
||||
setView,
|
||||
}: {
|
||||
hasMessages: boolean;
|
||||
setView?: (view: View) => void;
|
||||
}) {
|
||||
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
|
||||
const { currentModel } = useModel();
|
||||
const { recentModels } = useRecentModels(); // Get recent models
|
||||
const navigate = useNavigate();
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Add effect to handle clicks outside
|
||||
@@ -126,7 +133,8 @@ export default function BottomMenu({ hasMessages }) {
|
||||
border-t border-borderSubtle mt-2"
|
||||
onClick={() => {
|
||||
setIsModelMenuOpen(false);
|
||||
navigate('/settings');
|
||||
// Instead of navigate('/settings'), call setView('settings').
|
||||
setView?.('settings');
|
||||
}}
|
||||
>
|
||||
<span className="text-sm">Tools and Settings</span>
|
||||
|
||||
@@ -2,18 +2,20 @@ import { Popover, PopoverContent, PopoverTrigger, PopoverPortal } from '@radix-u
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { FaMoon, FaSun } from 'react-icons/fa';
|
||||
import VertDots from './ui/VertDots';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
// Removed react-router-dom import
|
||||
// import { useNavigate } from 'react-router-dom';
|
||||
import { More } from './icons';
|
||||
import { Settings, Grid, MessageSquare } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import type { View } from '../../ChatWindow';
|
||||
|
||||
interface VersionInfo {
|
||||
current_version: string;
|
||||
available_versions: string[];
|
||||
}
|
||||
|
||||
export default function MoreMenu() {
|
||||
const navigate = useNavigate();
|
||||
// Accept setView as a prop from the parent (e.g. ChatContent)
|
||||
export default function MoreMenu({ setView }: { setView?: (view: View) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [versions, setVersions] = useState<VersionInfo | null>(null);
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
@@ -229,7 +231,8 @@ export default function MoreMenu() {
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
navigate('/settings');
|
||||
// Instead of navigate('/settings'), call setView to switch.
|
||||
setView?.('settings');
|
||||
}}
|
||||
className="w-full text-left p-2 text-sm hover:bg-bgSubtle transition-colors"
|
||||
>
|
||||
@@ -264,12 +267,16 @@ export default function MoreMenu() {
|
||||
>
|
||||
Reset Provider
|
||||
</button>
|
||||
|
||||
{/* Provider keys settings */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
navigate('/keys');
|
||||
// Instead of navigate('/keys'), we might do setView('someKeysView') or open new window.
|
||||
// For now, just do nothing or set to some placeholder.
|
||||
// setView?.('keys');
|
||||
window.electron.createChatWindow();
|
||||
}}
|
||||
className="w-full text-left p-2 text-sm hover:bg-bgSubtle transition-colors"
|
||||
>
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { ChatContent } from '../../ChatWindow';
|
||||
import Settings from '../settings/Settings';
|
||||
import MoreModelsSettings from '../settings/models/MoreModels';
|
||||
import ConfigureProviders from '../settings/providers/ConfigureProviders';
|
||||
import { WelcomeScreen } from '../welcome_screen/WelcomeScreen';
|
||||
|
||||
export const ChatRoutes = ({
|
||||
chats,
|
||||
setChats,
|
||||
selectedChatId,
|
||||
setSelectedChatId,
|
||||
setProgressMessage,
|
||||
setWorking,
|
||||
}) => (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/chat/:id"
|
||||
element={
|
||||
<ChatContent
|
||||
chats={chats}
|
||||
setChats={setChats}
|
||||
selectedChatId={selectedChatId}
|
||||
setSelectedChatId={setSelectedChatId}
|
||||
initialQuery={null}
|
||||
setProgressMessage={setProgressMessage}
|
||||
setWorking={setWorking}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/settings/more-models" element={<MoreModelsSettings />} />
|
||||
<Route path="/settings/configure-providers" element={<ConfigureProviders />} />
|
||||
<Route path="/welcome" element={<WelcomeScreen />} />
|
||||
<Route path="*" element={<Navigate to="/chat/1" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Settings as SettingsType } from './types';
|
||||
import {
|
||||
@@ -15,6 +14,7 @@ import { ConfigureBuiltInExtensionModal } from './extensions/ConfigureBuiltInExt
|
||||
import BackButton from '../ui/BackButton';
|
||||
import { RecentModelsRadio } from './models/RecentModels';
|
||||
import { ExtensionItem } from './extensions/ExtensionItem';
|
||||
import type { View } from '../../ChatWindow';
|
||||
|
||||
const EXTENSIONS_DESCRIPTION =
|
||||
'The Model Context Protocol (MCP) is a system that allows AI models to securely connect with local or remote resources using standard server setups. It works like a client-server setup and expands AI capabilities using three main components: Prompts, Resources, and Tools.';
|
||||
@@ -46,9 +46,18 @@ const DEFAULT_SETTINGS: SettingsType = {
|
||||
extensions: BUILT_IN_EXTENSIONS,
|
||||
};
|
||||
|
||||
export default function Settings() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
// We'll accept two props:
|
||||
// onClose: to go back to chat
|
||||
// setView: to switch to moreModels, configureProviders, etc.
|
||||
export default function Settings({
|
||||
onClose,
|
||||
setView,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
setView: (view: View) => void;
|
||||
}) {
|
||||
// We'll read query params from window.location instead of react-router's useLocation
|
||||
const [searchParams] = useState(() => new URLSearchParams(window.location.search));
|
||||
|
||||
const [settings, setSettings] = React.useState<SettingsType>(() => {
|
||||
const saved = localStorage.getItem('user_settings');
|
||||
@@ -96,9 +105,8 @@ export default function Settings() {
|
||||
|
||||
// Handle URL parameters for auto-opening extension configuration
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const extensionId = params.get('extensionId');
|
||||
const showEnvVars = params.get('showEnvVars');
|
||||
const extensionId = searchParams.get('extensionId');
|
||||
const showEnvVars = searchParams.get('showEnvVars');
|
||||
|
||||
if (extensionId && showEnvVars === 'true') {
|
||||
// Find the extension in settings
|
||||
@@ -113,7 +121,9 @@ export default function Settings() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [location.search, settings.extensions]);
|
||||
// We only run this once on load
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settings.extensions]);
|
||||
|
||||
const handleExtensionToggle = async (extensionId: string) => {
|
||||
// Find the extension to get its current state
|
||||
@@ -160,28 +170,11 @@ export default function Settings() {
|
||||
extensions: prev.extensions.filter((ext) => ext.id !== extensionBeingConfigured.id),
|
||||
}));
|
||||
setExtensionBeingConfigured(null);
|
||||
navigate('/settings', { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavClick = (section: string, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const scrollArea = document.querySelector('[data-radix-scroll-area-viewport]');
|
||||
const element = document.getElementById(section.toLowerCase());
|
||||
|
||||
if (scrollArea && element) {
|
||||
const topPos = element.offsetTop;
|
||||
scrollArea.scrollTo({
|
||||
top: topPos,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleExtensionConfigSubmit = () => {
|
||||
setExtensionBeingConfigured(null);
|
||||
// Clear the URL parameters after configuration
|
||||
navigate('/settings', { replace: true });
|
||||
};
|
||||
|
||||
const isBuiltIn = (extensionId: string) => {
|
||||
@@ -197,7 +190,8 @@ export default function Settings() {
|
||||
<div className="px-8 pt-6 pb-4">
|
||||
<BackButton
|
||||
onClick={() => {
|
||||
navigate('/chat/1', { replace: true });
|
||||
// Instead of navigate('/chat/1', { replace: true });
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
<h1 className="text-3xl font-medium text-textStandard mt-1">Settings</h1>
|
||||
@@ -210,7 +204,10 @@ export default function Settings() {
|
||||
<div className="flex justify-between items-center mb-6 border-b border-borderSubtle px-8">
|
||||
<h2 className="text-xl font-medium text-textStandard">Models</h2>
|
||||
<button
|
||||
onClick={() => navigate('/settings/more-models')}
|
||||
onClick={() => {
|
||||
// Instead of navigate('/settings/more-models'):
|
||||
setView('moreModels');
|
||||
}}
|
||||
className="text-indigo-500 hover:text-indigo-600 text-sm"
|
||||
>
|
||||
Browse
|
||||
@@ -230,7 +227,6 @@ export default function Settings() {
|
||||
className="text-indigo-500 hover:text-indigo-600 text-sm"
|
||||
title="Add Manually"
|
||||
>
|
||||
{/* <Plus className="h-4 w-4" /> */}
|
||||
Add
|
||||
</button>
|
||||
|
||||
@@ -255,7 +251,7 @@ export default function Settings() {
|
||||
<ExtensionItem
|
||||
key={ext.id}
|
||||
{...ext}
|
||||
canConfigure={true} // Ensure gear icon always appears
|
||||
canConfigure={true}
|
||||
onToggle={handleExtensionToggle}
|
||||
onConfigure={(extension) => setExtensionBeingConfigured(extension)}
|
||||
/>
|
||||
@@ -273,7 +269,6 @@ export default function Settings() {
|
||||
isOpen={!!extensionBeingConfigured && isBuiltIn(extensionBeingConfigured.id)}
|
||||
onClose={() => {
|
||||
setExtensionBeingConfigured(null);
|
||||
navigate('/settings', { replace: true });
|
||||
}}
|
||||
extension={extensionBeingConfigured}
|
||||
onSubmit={handleExtensionConfigSubmit}
|
||||
@@ -283,8 +278,6 @@ export default function Settings() {
|
||||
isOpen={!!extensionBeingConfigured}
|
||||
onClose={() => {
|
||||
setExtensionBeingConfigured(null);
|
||||
// Clear URL parameters when closing manually
|
||||
navigate('/settings', { replace: true });
|
||||
}}
|
||||
extension={extensionBeingConfigured}
|
||||
onSubmit={handleExtensionConfigSubmit}
|
||||
|
||||
@@ -6,25 +6,27 @@ import BackButton from '../../ui/BackButton';
|
||||
import { SearchBar } from './Search';
|
||||
import { useModel } from './ModelContext';
|
||||
import { AddModelInline } from './AddModelInline';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
// Removed react-router-dom usage
|
||||
// import { useNavigate } from 'react-router-dom';
|
||||
import { ScrollArea } from '../../ui/scroll-area';
|
||||
import type { View } from '../../../ChatWindow';
|
||||
|
||||
export default function MoreModelsPage() {
|
||||
export default function MoreModelsPage({
|
||||
onClose,
|
||||
setView,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
setView: (view: View) => void;
|
||||
}) {
|
||||
const { currentModel } = useModel();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full">
|
||||
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>
|
||||
|
||||
<ScrollArea className="h-full w-full">
|
||||
{/*
|
||||
Instead of forcing one row, allow the layout
|
||||
to stack vertically on small screens:
|
||||
*/}
|
||||
|
||||
<div className="px-8 pt-6 pb-4">
|
||||
<BackButton />
|
||||
<BackButton onClick={onClose} />
|
||||
<h1 className="text-3xl font-medium text-textStandard mt-1">Browse models</h1>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +36,7 @@ export default function MoreModelsPage() {
|
||||
<div className="flex justify-between items-center mb-6 border-b border-borderSubtle px-8">
|
||||
<h2 className="text-xl font-medium text-textStandard">Models</h2>
|
||||
<button
|
||||
onClick={() => navigate('/settings/configure-providers')}
|
||||
onClick={() => setView('configureProviders')}
|
||||
className="text-indigo-500 hover:text-indigo-600 text-sm"
|
||||
>
|
||||
Configure
|
||||
|
||||
@@ -2,15 +2,22 @@ import React from 'react';
|
||||
import { ScrollArea } from '../../ui/scroll-area';
|
||||
import BackButton from '../../ui/BackButton';
|
||||
import { ConfigureProvidersGrid } from './ConfigureProvidersGrid';
|
||||
import type { View } from '../../../ChatWindow';
|
||||
|
||||
export default function ConfigureProviders() {
|
||||
export default function ConfigureProviders({
|
||||
onClose,
|
||||
setView,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
setView?: (view: View) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="h-screen w-full">
|
||||
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>
|
||||
|
||||
<ScrollArea className="h-full w-full">
|
||||
<div className="px-8 pt-6 pb-4">
|
||||
<BackButton />
|
||||
<BackButton onClick={onClose} />
|
||||
<h1 className="text-3xl font-medium text-textStandard mt-1">Configure</h1>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { createServer } from 'net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { getBinaryPath } from './utils/binaryPath';
|
||||
import log from './utils/logger';
|
||||
import { ChildProcessByStdio } from 'node:child_process';
|
||||
@@ -8,7 +9,7 @@ import { Readable } from 'node:stream';
|
||||
|
||||
// Find an available port to start goosed on
|
||||
export const findAvailablePort = (): Promise<number> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise((resolve, _reject) => {
|
||||
const server = createServer();
|
||||
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
@@ -56,16 +57,18 @@ export const startGoosed = async (
|
||||
): Promise<[number, string, ChildProcessByStdio<null, Readable, Readable>]> => {
|
||||
// we default to running goosed in home dir - if not specified
|
||||
const homeDir = os.homedir();
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
// Ensure dir is properly normalized for the platform
|
||||
if (!dir) {
|
||||
dir = homeDir;
|
||||
}
|
||||
dir = path.normalize(dir);
|
||||
|
||||
// Get the goosed binary path using the shared utility
|
||||
const goosedPath = getBinaryPath(app, 'goosed');
|
||||
let goosedPath = getBinaryPath(app, 'goosed');
|
||||
const port = await findAvailablePort();
|
||||
|
||||
// in case we want it
|
||||
//const isPackaged = app.isPackaged;
|
||||
log.info(`Starting goosed from: ${goosedPath} on port ${port} in dir ${dir}`);
|
||||
|
||||
// Define additional environment variables
|
||||
@@ -74,12 +77,15 @@ export const startGoosed = async (
|
||||
HOME: homeDir,
|
||||
// Set USERPROFILE for Windows
|
||||
USERPROFILE: homeDir,
|
||||
|
||||
// Set APPDATA for Windows
|
||||
APPDATA: process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'),
|
||||
// Set LOCAL_APPDATA for Windows
|
||||
LOCALAPPDATA: process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'),
|
||||
// Set PATH to include the binary directory
|
||||
PATH: `${path.dirname(goosedPath)}${path.delimiter}${process.env.PATH}`,
|
||||
// start with the port specified
|
||||
GOOSE_PORT: String(port),
|
||||
|
||||
GOOSE_SERVER__SECRET_KEY: process.env.GOOSE_SERVER__SECRET_KEY,
|
||||
|
||||
// Add any additional environment variables passed in
|
||||
...env,
|
||||
};
|
||||
@@ -87,12 +93,54 @@ export const startGoosed = async (
|
||||
// Merge parent environment with additional environment variables
|
||||
const processEnv = { ...process.env, ...additionalEnv };
|
||||
|
||||
// Spawn the goosed process with the user's home directory as cwd
|
||||
const goosedProcess = spawn(goosedPath, ['agent'], {
|
||||
// Add detailed logging for troubleshooting
|
||||
log.info(`Process platform: ${process.platform}`);
|
||||
log.info(`Process cwd: ${process.cwd()}`);
|
||||
log.info(`Target working directory: ${dir}`);
|
||||
log.info(`Environment HOME: ${processEnv.HOME}`);
|
||||
log.info(`Environment USERPROFILE: ${processEnv.USERPROFILE}`);
|
||||
log.info(`Environment APPDATA: ${processEnv.APPDATA}`);
|
||||
log.info(`Environment LOCALAPPDATA: ${processEnv.LOCALAPPDATA}`);
|
||||
log.info(`Environment PATH: ${processEnv.PATH}`);
|
||||
|
||||
// Ensure proper executable path on Windows
|
||||
if (isWindows && !goosedPath.toLowerCase().endsWith('.exe')) {
|
||||
goosedPath += '.exe';
|
||||
}
|
||||
log.info(`Binary path resolved to: ${goosedPath}`);
|
||||
|
||||
// Verify binary exists
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const stats = fs.statSync(goosedPath);
|
||||
log.info(`Binary exists: ${stats.isFile()}`);
|
||||
} catch (error) {
|
||||
log.error(`Binary not found at ${goosedPath}:`, error);
|
||||
throw new Error(`Binary not found at ${goosedPath}`);
|
||||
}
|
||||
|
||||
const spawnOptions = {
|
||||
cwd: dir,
|
||||
env: processEnv,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
// Hide terminal window on Windows
|
||||
windowsHide: true,
|
||||
// Run detached on Windows only to avoid terminal windows
|
||||
detached: isWindows,
|
||||
// Never use shell to avoid terminal windows
|
||||
shell: false,
|
||||
};
|
||||
|
||||
// Log spawn options for debugging
|
||||
log.info('Spawn options:', JSON.stringify(spawnOptions, null, 2));
|
||||
|
||||
// Spawn the goosed process
|
||||
const goosedProcess = spawn(goosedPath, ['agent'], spawnOptions);
|
||||
|
||||
// Only unref on Windows to allow it to run independently of the parent
|
||||
if (isWindows) {
|
||||
goosedProcess.unref();
|
||||
}
|
||||
|
||||
goosedProcess.stdout.on('data', (data) => {
|
||||
log.info(`goosed stdout for port ${port} and dir ${dir}: ${data.toString()}`);
|
||||
@@ -116,7 +164,16 @@ export const startGoosed = async (
|
||||
log.info(`Goosed isReady ${isReady}`);
|
||||
if (!isReady) {
|
||||
log.error(`Goosed server failed to start on port ${port}`);
|
||||
goosedProcess.kill();
|
||||
try {
|
||||
if (isWindows) {
|
||||
// On Windows, use taskkill to forcefully terminate the process tree
|
||||
spawn('taskkill', ['/pid', goosedProcess.pid.toString(), '/T', '/F']);
|
||||
} else {
|
||||
goosedProcess.kill();
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error while terminating goosed process:', error);
|
||||
}
|
||||
throw new Error(`Goosed server failed to start on port ${port}`);
|
||||
}
|
||||
|
||||
@@ -124,7 +181,16 @@ export const startGoosed = async (
|
||||
// TODO will need to do it at tab level next
|
||||
app.on('will-quit', () => {
|
||||
log.info('App quitting, terminating goosed server');
|
||||
goosedProcess.kill();
|
||||
try {
|
||||
if (isWindows) {
|
||||
// On Windows, use taskkill to forcefully terminate the process tree
|
||||
spawn('taskkill', ['/pid', goosedProcess.pid.toString(), '/T', '/F']);
|
||||
} else {
|
||||
goosedProcess.kill();
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error while terminating goosed process:', error);
|
||||
}
|
||||
});
|
||||
|
||||
log.info(`Goosed server successfully started on port ${port}`);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from 'electron';
|
||||
import started from 'electron-squirrel-startup';
|
||||
import path from 'node:path';
|
||||
import { handleSquirrelEvent } from './setup-events';
|
||||
import { startGoosed } from './goosed';
|
||||
import { getBinaryPath } from './utils/binaryPath';
|
||||
import { loadShellEnv } from './utils/loadEnv';
|
||||
@@ -26,25 +27,112 @@ import {
|
||||
saveSettings,
|
||||
updateEnvironmentVariables,
|
||||
} from './utils/settings';
|
||||
const { exec } = require('child_process');
|
||||
import * as crypto from 'crypto';
|
||||
import * as electron from 'electron';
|
||||
import { exec as execCallback } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const exec = promisify(execCallback);
|
||||
|
||||
// Handle Squirrel events for Windows installer
|
||||
if (process.platform === 'win32') {
|
||||
console.log('Windows detected, command line args:', process.argv);
|
||||
|
||||
if (handleSquirrelEvent()) {
|
||||
// squirrel event handled and app will exit in 1000ms, so don't do anything else
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Handle the protocol on Windows during first launch
|
||||
if (process.argv.length >= 2) {
|
||||
const url = process.argv[1];
|
||||
console.log('Checking URL from command line:', url);
|
||||
if (url.startsWith('goose://')) {
|
||||
console.log('Found goose:// URL in command line args');
|
||||
app.emit('open-url', { preventDefault: () => {} }, url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure single instance lock
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
|
||||
if (!gotTheLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on('second-instance', (event, commandLine, _workingDirectory) => {
|
||||
// Someone tried to run a second instance
|
||||
console.log('Second instance detected with args:', commandLine);
|
||||
|
||||
// Get existing window or create new one
|
||||
const existingWindows = BrowserWindow.getAllWindows();
|
||||
if (existingWindows.length > 0) {
|
||||
const window = existingWindows[0];
|
||||
if (window.isMinimized()) window.restore();
|
||||
window.focus();
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// Protocol handling for Windows
|
||||
const url = commandLine[commandLine.length - 1];
|
||||
console.log('Checking last arg for protocol:', url);
|
||||
if (url.startsWith('goose://')) {
|
||||
console.log('Found goose:// URL in second instance');
|
||||
// Send the URL to the window
|
||||
if (!window.webContents.isLoading()) {
|
||||
window.webContents.send('add-extension', url);
|
||||
} else {
|
||||
window.webContents.once('did-finish-load', () => {
|
||||
window.webContents.send('add-extension', url);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
||||
if (started) app.quit();
|
||||
|
||||
// Register protocol handler
|
||||
if (process.platform === 'win32') {
|
||||
const success = app.setAsDefaultProtocolClient('goose', process.execPath, ['--']);
|
||||
console.log('Registering protocol handler for Windows:', success ? 'success' : 'failed');
|
||||
} else {
|
||||
const success = app.setAsDefaultProtocolClient('goose');
|
||||
console.log('Registering protocol handler:', success ? 'success' : 'failed');
|
||||
}
|
||||
|
||||
// Log if we're the default protocol handler
|
||||
console.log('Is default protocol handler:', app.isDefaultProtocolClient('goose'));
|
||||
|
||||
// Triggered when the user opens "goose://..." links
|
||||
app.on('open-url', async (event, url) => {
|
||||
event.preventDefault();
|
||||
console.log('open-url:', url);
|
||||
|
||||
const recentDirs = loadRecentDirs();
|
||||
const openDir = recentDirs.length > 0 ? recentDirs[0] : null;
|
||||
// Get existing window or create new one
|
||||
let targetWindow: BrowserWindow;
|
||||
const existingWindows = BrowserWindow.getAllWindows();
|
||||
|
||||
// Create the new Chat window
|
||||
const newWindow = await createChat(app, undefined, openDir);
|
||||
if (existingWindows.length > 0) {
|
||||
targetWindow = existingWindows[0];
|
||||
if (targetWindow.isMinimized()) targetWindow.restore();
|
||||
targetWindow.focus();
|
||||
} else {
|
||||
const recentDirs = loadRecentDirs();
|
||||
const openDir = recentDirs.length > 0 ? recentDirs[0] : null;
|
||||
targetWindow = await createChat(app, undefined, openDir);
|
||||
}
|
||||
|
||||
newWindow.webContents.once('did-finish-load', () => {
|
||||
newWindow.webContents.send('add-extension', url);
|
||||
});
|
||||
// Wait for window to be ready before sending the extension URL
|
||||
if (!targetWindow.webContents.isLoading()) {
|
||||
targetWindow.webContents.send('add-extension', url);
|
||||
} else {
|
||||
targetWindow.webContents.once('did-finish-load', () => {
|
||||
targetWindow.webContents.send('add-extension', url);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
declare var MAIN_WINDOW_VITE_DEV_SERVER_URL: string;
|
||||
@@ -77,8 +165,7 @@ const getGooseProvider = () => {
|
||||
};
|
||||
|
||||
const generateSecretKey = () => {
|
||||
const crypto = require('crypto');
|
||||
let key = crypto.randomBytes(32).toString('hex');
|
||||
const key = crypto.randomBytes(32).toString('hex');
|
||||
process.env.GOOSE_SERVER__SECRET_KEY = key;
|
||||
return key;
|
||||
};
|
||||
@@ -98,7 +185,7 @@ const createLauncher = () => {
|
||||
const launcherWindow = new BrowserWindow({
|
||||
width: 600,
|
||||
height: 60,
|
||||
frame: false,
|
||||
frame: process.platform === 'darwin' ? false : true,
|
||||
transparent: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.ts'),
|
||||
@@ -110,8 +197,7 @@ const createLauncher = () => {
|
||||
});
|
||||
|
||||
// Center on screen
|
||||
const { screen } = require('electron');
|
||||
const primaryDisplay = screen.getPrimaryDisplay();
|
||||
const primaryDisplay = electron.screen.getPrimaryDisplay();
|
||||
const { width, height } = primaryDisplay.workAreaSize;
|
||||
const windowBounds = launcherWindow.getBounds();
|
||||
|
||||
@@ -141,18 +227,16 @@ let windowCounter = 0;
|
||||
const windowMap = new Map<number, BrowserWindow>();
|
||||
|
||||
const createChat = async (app, query?: string, dir?: string, version?: string) => {
|
||||
const env = version ? { GOOSE_AGENT_VERSION: version } : {};
|
||||
|
||||
// Apply current environment settings before creating chat
|
||||
updateEnvironmentVariables(envToggles);
|
||||
|
||||
const [port, working_dir, goosedProcess] = await startGoosed(app, dir);
|
||||
|
||||
const mainWindow = new BrowserWindow({
|
||||
titleBarStyle: 'hidden',
|
||||
trafficLightPosition: { x: 16, y: 10 },
|
||||
vibrancy: 'window',
|
||||
frame: false,
|
||||
titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default',
|
||||
trafficLightPosition: process.platform === 'darwin' ? { x: 16, y: 10 } : undefined,
|
||||
vibrancy: process.platform === 'darwin' ? 'window' : undefined,
|
||||
frame: process.platform === 'darwin' ? false : true,
|
||||
width: 750,
|
||||
height: 800,
|
||||
minWidth: 650,
|
||||
@@ -186,8 +270,7 @@ const createChat = async (app, query?: string, dir?: string, version?: string) =
|
||||
|
||||
// Load the index.html of the app.
|
||||
const queryParam = query ? `?initialQuery=${encodeURIComponent(query)}` : '';
|
||||
const { screen } = require('electron');
|
||||
const primaryDisplay = screen.getPrimaryDisplay();
|
||||
const primaryDisplay = electron.screen.getPrimaryDisplay();
|
||||
const { width } = primaryDisplay.workAreaSize;
|
||||
|
||||
// Increment window counter to track number of windows
|
||||
@@ -357,7 +440,7 @@ ipcMain.handle('select-file-or-directory', async () => {
|
||||
|
||||
ipcMain.handle('check-ollama', async () => {
|
||||
try {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise((resolve) => {
|
||||
// Run `ps` and filter for "ollama"
|
||||
exec('ps aux | grep -iw "[o]llama"', (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
|
||||
29
ui/desktop/src/preload.js
Normal file
29
ui/desktop/src/preload.js
Normal file
@@ -0,0 +1,29 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron')
|
||||
|
||||
const config = JSON.parse(process.argv.find((arg) => arg.startsWith('{')) || '{}');
|
||||
|
||||
contextBridge.exposeInMainWorld('appConfig', {
|
||||
get: (key) => config[key],
|
||||
getAll: () => config,
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('electron', {
|
||||
getConfig: () => config,
|
||||
hideWindow: () => ipcRenderer.send('hide-window'),
|
||||
directoryChooser: (replace) => ipcRenderer.send('directory-chooser', replace),
|
||||
createChatWindow: (query, dir, version) => ipcRenderer.send('create-chat-window', query, dir, version),
|
||||
logInfo: (txt) => ipcRenderer.send('logInfo', txt),
|
||||
showNotification: (data) => ipcRenderer.send('notify', data),
|
||||
createWingToWingWindow: (query) => ipcRenderer.send('create-wing-to-wing-window', query),
|
||||
openInChrome: (url) => ipcRenderer.send('open-in-chrome', url),
|
||||
fetchMetadata: (url) => ipcRenderer.invoke('fetch-metadata', url),
|
||||
reloadApp: () => ipcRenderer.send('reload-app'),
|
||||
checkForOllama: () => ipcRenderer.invoke('check-ollama'),
|
||||
selectFileOrDirectory: () => ipcRenderer.invoke('select-file-or-directory'),
|
||||
startPowerSaveBlocker: () => ipcRenderer.invoke('start-power-save-blocker'),
|
||||
stopPowerSaveBlocker: () => ipcRenderer.invoke('stop-power-save-blocker'),
|
||||
getBinaryPath: (binaryName) => ipcRenderer.invoke('get-binary-path', binaryName),
|
||||
on: (channel, callback) => ipcRenderer.on(channel, callback),
|
||||
off: (channel, callback) => ipcRenderer.off(channel, callback),
|
||||
send: (key) => ipcRenderer.send(key)
|
||||
});
|
||||
71
ui/desktop/src/setup-events.ts
Normal file
71
ui/desktop/src/setup-events.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { app } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { spawn as spawnProcess } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export function handleSquirrelEvent(): boolean {
|
||||
if (process.argv.length === 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const appFolder = path.resolve(process.execPath, '..');
|
||||
const rootAtomFolder = path.resolve(appFolder, '..');
|
||||
const updateDotExe = path.resolve(path.join(rootAtomFolder, 'Update.exe'));
|
||||
const exeName = path.basename(process.execPath);
|
||||
|
||||
const spawnUpdate = function (args: string[]) {
|
||||
try {
|
||||
return spawnProcess(updateDotExe, args, { detached: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to spawn update process:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const squirrelEvent = process.argv[1];
|
||||
switch (squirrelEvent) {
|
||||
case '--squirrel-install':
|
||||
case '--squirrel-updated': {
|
||||
// Register protocol handler
|
||||
spawnUpdate(['--createShortcut', exeName]);
|
||||
|
||||
// Register protocol
|
||||
const regCommand = `Windows Registry Editor Version 5.00
|
||||
|
||||
[HKEY_CLASSES_ROOT\\goose]
|
||||
@="URL:Goose Protocol"
|
||||
"URL Protocol"=""
|
||||
|
||||
[HKEY_CLASSES_ROOT\\goose\\DefaultIcon]
|
||||
@="\\"${process.execPath.replace(/\\/g, '\\\\')},1\\""
|
||||
|
||||
[HKEY_CLASSES_ROOT\\goose\\shell]
|
||||
|
||||
[HKEY_CLASSES_ROOT\\goose\\shell\\open]
|
||||
|
||||
[HKEY_CLASSES_ROOT\\goose\\shell\\open\\command]
|
||||
@="\\"${process.execPath.replace(/\\/g, '\\\\')}\\" \\"%1\\""`;
|
||||
|
||||
fs.writeFileSync('goose-protocol.reg', regCommand);
|
||||
spawnProcess('regedit.exe', ['/s', 'goose-protocol.reg']);
|
||||
|
||||
setTimeout(() => app.quit(), 1000);
|
||||
return true;
|
||||
}
|
||||
|
||||
case '--squirrel-uninstall': {
|
||||
// Remove protocol handler
|
||||
spawnUpdate(['--removeShortcut', exeName]);
|
||||
setTimeout(() => app.quit(), 1000);
|
||||
return true;
|
||||
}
|
||||
|
||||
case '--squirrel-obsolete': {
|
||||
app.quit();
|
||||
return true;
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,55 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import Electron from 'electron';
|
||||
import log from './logger';
|
||||
|
||||
export const getBinaryPath = (app: Electron.App, binaryName: string): string => {
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const isPackaged = app.isPackaged;
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
// On Windows, use .cmd for npx and .exe for uvx
|
||||
const executableName = isWindows
|
||||
? binaryName === 'npx'
|
||||
? 'npx.cmd'
|
||||
: `${binaryName}.exe`
|
||||
: binaryName;
|
||||
|
||||
// List of possible paths to check
|
||||
const possiblePaths = [];
|
||||
|
||||
if (isDev && !isPackaged) {
|
||||
// In development, use the absolute path from the project root
|
||||
return path.join(
|
||||
process.cwd(),
|
||||
'src',
|
||||
'bin',
|
||||
process.platform === 'win32' ? `${binaryName}.exe` : binaryName
|
||||
// In development, check multiple possible locations
|
||||
possiblePaths.push(
|
||||
path.join(process.cwd(), 'src', 'bin', executableName),
|
||||
path.join(process.cwd(), 'bin', executableName),
|
||||
path.join(process.cwd(), '..', '..', 'target', 'release', executableName)
|
||||
);
|
||||
} else {
|
||||
// In production, use the path relative to the app resources
|
||||
return path.join(
|
||||
process.resourcesPath,
|
||||
'bin',
|
||||
process.platform === 'win32' ? `${binaryName}.exe` : binaryName
|
||||
// In production, check resources paths
|
||||
possiblePaths.push(
|
||||
path.join(process.resourcesPath, 'bin', executableName),
|
||||
path.join(app.getAppPath(), 'resources', 'bin', executableName)
|
||||
);
|
||||
}
|
||||
|
||||
// Log all paths we're checking
|
||||
log.info('Checking binary paths:', possiblePaths);
|
||||
|
||||
// Try each path and return the first one that exists
|
||||
for (const binPath of possiblePaths) {
|
||||
try {
|
||||
if (fs.existsSync(binPath)) {
|
||||
log.info(`Found binary at: ${binPath}`);
|
||||
return binPath;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`Error checking path ${binPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, we couldn't find the binary
|
||||
const error = `Could not find ${binaryName} binary in any of the expected locations: ${possiblePaths.join(', ')}`;
|
||||
log.error(error);
|
||||
throw new Error(error);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user