feat: goose windows (#880)

Co-authored-by: Ryan Versaw <ryan@versaw.com>
This commit is contained in:
Max Novich
2025-02-10 15:05:13 -08:00
committed by GitHub
parent 98aecbef23
commit cfd3ee8fd9
43 changed files with 1327 additions and 456 deletions

View File

@@ -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

View 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/

View File

@@ -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"

View File

@@ -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.

View File

@@ -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
# ------------------------------------

View File

@@ -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" }

View File

@@ -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

View File

@@ -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"

View File

@@ -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!(

View File

@@ -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
}

View File

@@ -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()));

View 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")
}
}

View 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")
}
}

View 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"))
}
}

View File

@@ -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",

View File

@@ -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]

View 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")
}
}

View File

@@ -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")

View File

@@ -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"

View File

@@ -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)
}

View File

@@ -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()))?;

View File

@@ -2,3 +2,4 @@ node_modules
.vite/
out
src/bin/goosed
/src/bin/goosed.exe

View File

@@ -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',

View File

@@ -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",

View 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);
}

View File

@@ -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>
);
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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

Binary file not shown.

BIN
ui/desktop/src/bin/uvx.exe Normal file

Binary file not shown.

View File

@@ -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>

View File

@@ -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"
>

View File

@@ -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>
);

View File

@@ -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}

View File

@@ -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

View File

@@ -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>

View File

@@ -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}`);

View File

@@ -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
View 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)
});

View 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;
}
}

View File

@@ -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);
};