Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/release-binaries.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ jobs:
shell: bash
run: cargo xtask package binary --target "${{ steps.host.outputs.target }}"

- name: Package macOS installer
if: matrix.os == 'macos-latest'
shell: bash
run: cargo xtask package macos --target "${{ steps.host.outputs.target }}"

- name: Set up .NET SDK
if: matrix.os == 'windows-latest'
uses: actions/setup-dotnet@v4
Expand Down Expand Up @@ -100,6 +105,15 @@ jobs:
if-no-files-found: error
path: target/x86_64-pc-windows-msvc/release/msi/*-setup.exe


- name: Upload macOS installer artifact
if: matrix.os == 'macos-latest'
uses: actions/upload-artifact@v4
with:
name: daat-locus-${{ steps.host.outputs.target }}-macos-installer
if-no-files-found: error
path: target/${{ steps.host.outputs.target }}/release/macos/*.pkg

publish:
name: Upload release assets
needs: build
Expand Down Expand Up @@ -149,3 +163,4 @@ jobs:
files: |
target/release-artifacts/*.tar.zst
target/release-artifacts/*-setup.exe
target/release-artifacts/*.pkg
99 changes: 95 additions & 4 deletions crates/daat-locus-launcher/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ use std::{
net::{SocketAddr, TcpStream},
path::{Path, PathBuf},
process::{Command, Stdio},
time::{Duration, SystemTime, UNIX_EPOCH},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
const CONFIG_FILE_NAME: &str = "config.toml";
const DEFAULT_DAEMON_PORT: u16 = 53825;
const ENABLE_TRAY_ENV: &str = "DAAT_LOCUS_ENABLE_TRAY";
const NO_TRAY_ENV: &str = "DAAT_LOCUS_NO_TRAY";
const LAUNCHER_LOG_FILE_NAME: &str = "launcher.log";
const DAEMON_STARTUP_TIMEOUT: Duration = Duration::from_secs(30);
const DAEMON_STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(200);
#[cfg(windows)]
const MAIN_BINARY_NAME: &str = "daat-locus.exe";
#[cfg(not(windows))]
Expand All @@ -26,7 +28,8 @@ fn main() {
fn run() -> io::Result<()> {
let home = daat_locus_home();
let config_path = home.join("config").join(CONFIG_FILE_NAME);
if !config_path.is_file() {
let config_exists = config_path.is_file();
if !config_exists && !should_start_without_config() {
log_launcher(
&home,
&format!(
Expand All @@ -36,8 +39,20 @@ fn run() -> io::Result<()> {
);
return Ok(());
}
let port = configured_daemon_port(&config_path).unwrap_or(DEFAULT_DAEMON_PORT);
let port = if config_exists {
configured_daemon_port(&config_path).unwrap_or(DEFAULT_DAEMON_PORT)
} else {
log_launcher(
&home,
&format!(
"config file missing at {}; using default daemon port {DEFAULT_DAEMON_PORT}",
config_path.display()
),
);
DEFAULT_DAEMON_PORT
};
if daemon_port_is_active(port) {
open_webui_if_requested(port, &home);
return Ok(());
}
let main_binary = installed_main_binary()?;
Expand All @@ -46,7 +61,16 @@ fn run() -> io::Result<()> {
log_launcher(&home, &message);
return Err(io::Error::new(io::ErrorKind::NotFound, message));
}
spawn_daemon(&main_binary, &home)
spawn_daemon(&main_binary, &home)?;
if wait_for_daemon_port(port) {
open_webui_if_requested(port, &home);
} else {
log_launcher(
&home,
&format!("daemon did not become ready on port {port} before launcher timeout"),
);
}
Ok(())
}
fn installed_main_binary() -> io::Result<PathBuf> {
let launcher = env::current_exe()?;
Expand Down Expand Up @@ -130,6 +154,73 @@ fn daemon_port_is_active(port: u16) -> bool {
let addr = SocketAddr::from(([127, 0, 0, 1], port));
TcpStream::connect_timeout(&addr, Duration::from_millis(250)).is_ok()
}
fn wait_for_daemon_port(port: u16) -> bool {
let deadline = Instant::now() + DAEMON_STARTUP_TIMEOUT;
while Instant::now() < deadline {
if daemon_port_is_active(port) {
return true;
}
std::thread::sleep(DAEMON_STARTUP_POLL_INTERVAL);
}
false
}
fn open_webui_if_requested(port: u16, home: &Path) {
if !should_open_webui_after_launch() {
return;
}
let url = webui_url(port, home);
if let Err(err) = open_url(&url) {
log_launcher(home, &format!("failed to open WebUI {url}: {err}"));
}
}
fn webui_url(port: u16, home: &Path) -> String {
let setup_url = format!("http://localhost:{port}/?setup=1");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid forcing setup for configured launches

When a macOS user launches the installed app after completing configuration, this always opens /?setup=1, and App.tsx treats that query as a hard force into the setup wizard even when /config/readiness returns complete; the only code path that removes the query is handleSetupReadinessChanged, which does not run on the initial readiness fetch. This makes normal app launches land on the first-run setup flow instead of the agent workspace. Only add setup=1 for true first-run/unconfigured launches, or have the WebUI clear it once readiness is complete.

Useful? React with 👍 / 👎.

let Some(token) = local_daemon_token(home) else {
return setup_url;
};
format!(
"{setup_url}#daemon_token={}",
percent_encode_url_component(&token)
)
}
fn local_daemon_token(home: &Path) -> Option<String> {
let token = fs::read_to_string(home.join("runtime").join("daemon.token")).ok()?;
let token = token.trim();
(!token.is_empty()).then(|| token.to_string())
}
fn percent_encode_url_component(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
encoded.push(byte as char)
}
_ => encoded.push_str(&format!("%{byte:02X}")),
}
}
encoded
}
fn should_start_without_config() -> bool {
cfg!(target_os = "macos")
}
fn should_open_webui_after_launch() -> bool {
cfg!(target_os = "macos")
}
#[cfg(target_os = "macos")]
fn open_url(url: &str) -> io::Result<()> {
Command::new("open").arg(url).spawn().map(|_| ())
}
#[cfg(target_os = "windows")]
fn open_url(url: &str) -> io::Result<()> {
Command::new("cmd")
.args(["/C", "start", "", url])
.spawn()
.map(|_| ())
}
#[cfg(all(unix, not(target_os = "macos")))]
fn open_url(url: &str) -> io::Result<()> {
Command::new("xdg-open").arg(url).spawn().map(|_| ())
}
fn daat_locus_home() -> PathBuf {
if let Ok(value) = env::var("DAAT_LOCUS_HOME")
&& !value.trim().is_empty()
Expand Down
32 changes: 32 additions & 0 deletions packaging/macos/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>{{product_name}}</string>
<key>CFBundleExecutable</key>
<string>{{executable_name}}</string>
<key>CFBundleIconFile</key>
<string>{{icon_file}}</string>
<key>CFBundleIdentifier</key>
<string>{{bundle_identifier}}</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>{{product_name}}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>{{version}}</string>
<key>CFBundleVersion</key>
<string>{{version}}</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
11 changes: 3 additions & 8 deletions src/daemon_tray.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ mod platform_tray {
platform::run_return::EventLoopExtRunReturn,
};
use tray_icon::{
Icon, MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent,
Icon, MouseButton, TrayIcon, TrayIconBuilder, TrayIconEvent,
menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem},
};

Expand Down Expand Up @@ -125,12 +125,7 @@ mod platform_tray {
*control_flow = ControlFlow::Exit;
}
}
Event::UserEvent(TrayEvent::TrayIcon(TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
}))
| Event::UserEvent(TrayEvent::TrayIcon(TrayIconEvent::DoubleClick {
Event::UserEvent(TrayEvent::TrayIcon(TrayIconEvent::DoubleClick {
button: MouseButton::Left,
..
})) => {
Expand Down Expand Up @@ -158,7 +153,7 @@ mod platform_tray {
TrayIconBuilder::new()
.with_tooltip(format!("DaatLocus Daemon on :{port}"))
.with_menu(Box::new(menu))
.with_menu_on_left_click(false)
.with_menu_on_left_click(true)
.with_icon(daemon_icon()?)
.with_icon_as_template(true)
.build()
Expand Down
34 changes: 33 additions & 1 deletion webui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
} from "@/components/ui/empty";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { Spinner } from "@/components/ui/spinner";
import { getStoredDaemonToken } from "@/lib/daemon-auth";
import { getStoredDaemonToken, storeDaemonToken } from "@/lib/daemon-auth";
import {
createSession,
deleteSession,
Expand All @@ -37,6 +37,38 @@ const THEME_STORAGE_KEY = "daat-locus.webui.theme";

const APP_DOCUMENT_TITLE = "Daat Locus";

consumeDaemonTokenFromHash();

function consumeDaemonTokenFromHash() {
if (typeof window === "undefined") {
return;
}

const hash = window.location.hash;
const hashValue = hash.startsWith("#") ? hash.slice(1) : hash;
if (!hashValue.includes("=")) {
return;
}

const params = new URLSearchParams(
hashValue.startsWith("?") ? hashValue.slice(1) : hashValue,
);
const token = params.get("daemon_token")?.trim();
if (!token) {
return;
}

storeDaemonToken(token);
params.delete("daemon_token");

const nextHash = params.toString();
window.history.replaceState(
window.history.state,
"",
`${window.location.pathname}${window.location.search}${nextHash ? `#${nextHash}` : ""}`,
);
}

export default function App() {
if (shouldRenderMockSetupPage()) {
return <MockSetupApp />;
Expand Down
Loading
Loading