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
459 changes: 459 additions & 0 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@fontsource/ibm-plex-sans": "^5.3.0",
"@tauri-apps/api": "^2.12.0",
"@tauri-apps/plugin-dialog": "^2.8.0",
"@tauri-apps/plugin-notification": "^2.5.0",
"@tauri-apps/plugin-updater": "^2.13.0",
"lucide-react": "^1.48.0",
"react": "^19.3.0",
Expand Down
10 changes: 10 additions & 0 deletions app/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,15 @@ tauri-build = { version = "2", features = [] }
dirs = "7.0.0"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
tauri = { version = "2", features = [] }
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-dialog = "2"
tauri-plugin-notification = "2.5.0"
tauri-plugin-single-instance = "2.5.1"
tauri-plugin-updater = "2"
toml = "1.1.6"

[target."cfg(windows)".dependencies]
winreg = "0.55.0"

[lints]
workspace = true
7 changes: 6 additions & 1 deletion app/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,14 @@
"core:window:allow-close",
"core:window:allow-hide",
"core:window:allow-destroy",
"core:window:allow-show",
"core:window:allow-unminimize",
"core:window:allow-set-focus",
"core:app:allow-default-window-icon",
"core:window:allow-set-overlay-icon",
"core:webview:allow-set-webview-zoom",
"updater:default",
"dialog:allow-open"
"dialog:allow-open",
"notification:default"
]
}
35 changes: 33 additions & 2 deletions app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

mod claude;
mod daemon;
mod sign_in;
mod window;

use std::path::Path;

Expand Down Expand Up @@ -126,6 +128,18 @@ async fn reveal_file(path: String) -> Result<(), String> {
.await
}

/// Opens Botloft when the owner signs in to Windows, or stops doing so.
#[tauri::command]
async fn open_at_sign_in(on: bool) -> Result<(), String> {
blocking(move || sign_in::set(on)).await
}

/// Whether Windows opened this app at sign-in, with its window hidden.
#[tauri::command]
fn launched_at_sign_in() -> bool {
sign_in::launched_at_sign_in()
}

/// Whether `url` is a plain web link, safe to hand to the default browser.
fn is_web_link(url: &str) -> bool {
let rest = url
Expand Down Expand Up @@ -157,11 +171,26 @@ async fn open_url(url: String) -> Result<(), String> {

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
let builder = tauri::Builder::default();
// Opening Botloft again brings the running one forward. Not in a dev
// build, which runs beside the installed app.
#[cfg(not(debug_assertions))]
let builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
window::reopened(app);
}));
builder
// Checks the release feed and installs signed updates (spec 15.5).
.plugin(tauri_plugin_updater::Builder::new().build())
// The folder picker for a crew's work folder (spec 5).
.plugin(tauri_plugin_dialog::init())
// Windows notifications when a bot needs the owner (spec 15.1).
.plugin(tauri_plugin_notification::init())
.setup(|app| {
if !sign_in::launched_at_sign_in() {
window::bring_forward(app.handle());
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
daemon_status,
daemon_install,
Expand All @@ -172,7 +201,9 @@ pub fn run() {
open_path,
open_file,
reveal_file,
open_url
open_url,
open_at_sign_in,
launched_at_sign_in
])
.run(tauri::generate_context!())
.expect("error while running the Botloft app");
Expand Down
99 changes: 99 additions & 0 deletions app/src-tauri/src/sign_in.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//! Opening Botloft when the owner signs in to Windows (spec 15.2): a value
//! in the current user's Run key, `"<app>" --autostart`. The daemon starts
//! from its own scheduled task (spec 14); this is only the app, near the
//! clock or with its window.

use std::path::Path;

/// Tells the app that Windows opened it at sign-in.
pub const AUTOSTART_ARG: &str = "--autostart";

/// Whether Windows opened this app at sign-in.
pub fn launched_at_sign_in() -> bool {
std::env::args().any(|arg| arg == AUTOSTART_ARG)
}

/// The Run key's command line, quoted: a path with a space, like
/// `C:\Users\Ana Lima\...`, would otherwise be split.
fn command_line(exe: &Path) -> String {
format!("\"{}\" {AUTOSTART_ARG}", exe.display())
}

#[cfg(windows)]
mod registry {
use std::io;

use winreg::RegKey;
use winreg::enums::{HKEY_CURRENT_USER, KEY_READ, KEY_SET_VALUE, REG_BINARY};
use winreg::types::ToRegValue as _;

const RUN_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Run";
/// Where Task Manager keeps its "Startup apps" switch.
const APPROVED_KEY: &str =
r"Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run";
const VALUE: &str = "Botloft";
/// What Task Manager writes for an app allowed to start.
const APPROVED: [u8; 12] = [2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];

pub fn set(command: Option<&str>) -> io::Result<()> {
let user = RegKey::predef(HKEY_CURRENT_USER);
let (run, _) = user.create_subkey(RUN_KEY)?;
let Some(command) = command else {
return match run.delete_value(VALUE) {
Err(err) if err.kind() != io::ErrorKind::NotFound => Err(err),
_ => Ok(()),
};
};
run.set_raw_value(VALUE, &command.to_reg_value())?;
// Turned off in Task Manager earlier: turning it on here wins.
if let Ok(approved) = user.open_subkey_with_flags(APPROVED_KEY, KEY_READ | KEY_SET_VALUE)
&& approved.get_raw_value(VALUE).is_ok()
{
approved.set_raw_value(
VALUE,
&winreg::RegValue {
bytes: APPROVED.to_vec(),
vtype: REG_BINARY,
},
)?;
}
Ok(())
}
}

/// Opens the app at sign-in, or stops doing so. A dev build never
/// registers itself: it needs the dev server.
pub fn set(on: bool) -> Result<(), String> {
if cfg!(debug_assertions) {
return Ok(());
}
#[cfg(windows)]
{
let exe = std::env::current_exe().map_err(|err| err.to_string())?;
let command = on.then(|| command_line(&exe));
registry::set(command.as_deref())
.map_err(|err| format!("cannot change what opens at sign-in: {err}"))
}
#[cfg(not(windows))]
{
let _ = on;
Err("opening at sign-in is only supported on Windows".into())
}
}

#[cfg(test)]
mod tests {
use std::path::Path;

use super::command_line;

#[test]
fn the_command_line_is_quoted() {
assert_eq!(
command_line(Path::new(
r"C:\Users\Ana Lima\AppData\Local\Botloft\Botloft.exe"
)),
r#""C:\Users\Ana Lima\AppData\Local\Botloft\Botloft.exe" --autostart"#
);
}
}
27 changes: 27 additions & 0 deletions app/src-tauri/src/window.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//! The main window, which starts hidden (spec 15.2): shown at once when the
//! owner opens Botloft, and left near the clock when Windows opens it at
//! sign-in, unless the owner wants the window then too.

use tauri::{AppHandle, Manager as _};

/// What the app hears when Botloft is opened again while it runs, from the
/// Start menu or a notification: it may show what the notification was about.
#[cfg(not(debug_assertions))]
const REOPENED: &str = "botloft://reopened";

pub fn bring_forward(app: &AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}

/// Botloft was opened again: the running app comes forward instead. Only
/// in a release build, the only one kept to a single instance.
#[cfg(not(debug_assertions))]
pub fn reopened(app: &AppHandle) {
use tauri::Emitter as _;
bring_forward(app);
let _ = app.emit(REOPENED, ());
}
3 changes: 2 additions & 1 deletion app/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"height": 800,
"minWidth": 900,
"minHeight": 560,
"decorations": false
"decorations": false,
"visible": false
}
],
"security": {
Expand Down
2 changes: 2 additions & 0 deletions app/src-tauri/windows/hooks.nsh
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,7 @@
${If} $UpdateMode <> 1
nsExec::Exec '"$INSTDIR\botloftd.exe" service uninstall'
Pop $0
; Nor does the app open at sign-in any more (spec 15.2).
DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "Botloft"
${EndIf}
!macroend
2 changes: 2 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Onboarding } from "./features/onboarding/Onboarding";
import type { Host } from "./lib/host";
import { useCloseBehavior } from "./shell/closing";
import { useMotionRoot } from "./shell/motionRoot";
import { useSignInWindow } from "./shell/signIn";
import { TitleBar } from "./shell/TitleBar";
import { useThemeRoot } from "./shell/theme";
import { Workspace } from "./shell/Workspace";
Expand All @@ -19,6 +20,7 @@ export function App({ host, connect }: { host: Host; connect: Connect }) {
useCloseBehavior(host);
const [link] = useState(() => createLink(host, connect));
const current = useStore(link, (state) => state.current);
useSignInWindow(host, current.step);
useEffect(() => {
link.getState().check();
}, [link]);
Expand Down
35 changes: 35 additions & 0 deletions app/src/features/account/AlertsSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Settings, "Notifications" (spec 15.1): when Botloft tells the owner, with
// a Windows notification while it is not in front, and whether a sound
// goes with it.

import { useT } from "../../i18n";
import { prefs, usePref } from "../../shell/prefs";
import { Section, Toggle } from "./settingsParts";

export function AlertsSettings() {
const s = useT().account.settings;
const needs = usePref(prefs.notifyNeeds);
const done = usePref(prefs.notifyDone);
const sound = usePref(prefs.sound);
const tray = usePref(prefs.tray);
const keep = usePref(prefs.whenClosed) === "keep";
const nearClock = tray && keep;
return (
<Section title={s.alerts}>
<Toggle
label={s.notifyNeeds}
hint={s.notifyNeedsHint}
checked={needs}
onChange={prefs.notifyNeeds.set}
/>
<Toggle
label={s.notifyDone}
hint={s.notifyDoneHint}
checked={done}
onChange={prefs.notifyDone.set}
/>
<Toggle label={s.sound} hint={s.soundHint} checked={sound} onChange={prefs.sound.set} />
{!nearClock && <p className="text-muted text-xs leading-relaxed">{s.alertsNeedTray}</p>}
</Section>
);
}
29 changes: 27 additions & 2 deletions app/src/features/account/GeneralSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Settings, "General": the language, and what Botloft does in the
// background (spec 15.1).
// Settings, "General": the language, what Botloft does in the background
// and when Windows starts (spec 15.1).

import {
LOCALES,
Expand All @@ -18,6 +18,9 @@ export function GeneralSettings() {
const t = useT();
const s = t.account.settings;
const whenClosed = usePref(prefs.whenClosed);
const tray = usePref(prefs.tray);
const openAtSignIn = usePref(prefs.openAtSignIn);
const nearClock = whenClosed === "keep" && tray;
const { settings, change } = useDaemonSettings();
const startWithWindows = settings?.startWithWindows ?? true;
const { choice: locale } = useLocale();
Expand All @@ -39,13 +42,35 @@ export function GeneralSettings() {
checked={whenClosed === "keep"}
onChange={(on) => prefs.whenClosed.set(on ? "keep" : "stop")}
/>
{whenClosed === "keep" && (
<Toggle
label={s.tray}
hint={tray ? s.trayOn : s.trayOff}
checked={tray}
onChange={prefs.tray.set}
/>
)}
<Toggle
label={s.startWithWindows}
hint={startWithWindows ? s.startWithWindowsOn : s.startWithWindowsOff}
checked={startWithWindows}
disabled={!settings}
onChange={(on) => change({ startWithWindows: on })}
/>
{startWithWindows && settings && (
<Toggle
label={s.openAtSignIn}
hint={
openAtSignIn
? s.openAtSignInOn
: nearClock
? s.openAtSignInNearClock
: s.openAtSignInOff
}
checked={openAtSignIn}
onChange={prefs.openAtSignIn.set}
/>
)}
<Toggle
label={s.keepAwake}
hint={s.keepAwakeHint}
Expand Down
Loading
Loading