Skip to content

Commit d436993

Browse files
committed
Merge branch 'main' into feat/delete-recycle
2 parents 0f20b9f + 4fdf715 commit d436993

76 files changed

Lines changed: 3035 additions & 522 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎app/src-setup/src/installed.rs‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,10 @@ mod registry {
3939

4040
const UNINSTALL_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Uninstall\Botloft";
4141
/// Tauri's NSIS remembers the install folder under
42-
/// `Software\<publisher>\<product>`; the publisher is "github", the second
43-
/// part of the identifier (spec 15.4).
44-
const FOLDER_KEY: &str = r"Software\github\Botloft";
42+
/// `Software\<publisher>\<product>`. The publisher is "Botloft"; installs
43+
/// made while it was "github", Tauri's default from the identifier, are
44+
/// adopted by the installer's hooks (spec 15.4), which look in this order.
45+
const FOLDER_KEYS: [&str; 2] = [r"Software\Botloft\Botloft", r"Software\github\Botloft"];
4546

4647
pub fn entry(name: &str) -> Option<String> {
4748
RegKey::predef(HKEY_CURRENT_USER)
@@ -52,11 +53,14 @@ mod registry {
5253
}
5354

5455
pub fn remembered_folder() -> Option<String> {
55-
RegKey::predef(HKEY_CURRENT_USER)
56-
.open_subkey(FOLDER_KEY)
57-
.ok()?
58-
.get_value::<String, _>("")
59-
.ok()
56+
let user = RegKey::predef(HKEY_CURRENT_USER);
57+
FOLDER_KEYS.iter().find_map(|key| {
58+
user.open_subkey(key)
59+
.ok()?
60+
.get_value::<String, _>("")
61+
.ok()
62+
.filter(|folder| !folder.trim().is_empty())
63+
})
6064
}
6165
}
6266

‎app/src-setup/tauri.conf.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
}
1414
},
1515
"bundle": {
16+
"publisher": "Botloft",
1617
"copyright": "Copyright 2026 Phelipe Lorran",
1718
"icon": ["../src-tauri/icons/icon.ico"]
1819
}

‎app/src-tauri/tauri.conf.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
},
3838
"bundle": {
3939
"active": true,
40+
"publisher": "Botloft",
4041
"copyright": "Copyright 2026 Phelipe Lorran",
4142
"targets": [
4243
"nsis"

‎app/src-tauri/windows/hooks.nsh‎

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,64 @@
66
; next to the title, as the Botloft mascot on the page's white.
77
!define MUI_HEADERIMAGE_RIGHT
88

9+
; The installer's own file properties name the publisher too; Tauri's
10+
; template leaves the company out.
11+
VIAddVersionKey "CompanyName" "Botloft"
12+
13+
; Tauri keeps the install folder in the registry under
14+
; Software\<publisher>\<product>. The publisher used to be "github", Tauri's
15+
; default from the identifier; it is "Botloft" now (`bundle.publisher`).
16+
; An install made under the old name is adopted under the new one, so an
17+
; update goes to the folder Botloft is in, and "uninstall before installing"
18+
; still finds that folder.
19+
!ifndef BOTLOFT_FOLDER_KEY
20+
!define BOTLOFT_FOLDER_KEY "Software\Botloft\Botloft"
21+
!endif
22+
!ifndef BOTLOFT_OLD_FOLDER_KEY
23+
!define BOTLOFT_OLD_FOLDER_KEY "Software\github\Botloft"
24+
!endif
25+
!ifndef BOTLOFT_OLD_PUBLISHER_KEY
26+
!define BOTLOFT_OLD_PUBLISHER_KEY "Software\github"
27+
!endif
28+
29+
; Copies the folder of an install made under the old publisher to the new
30+
; key, and installs there unless /D chose another folder. It does nothing
31+
; once the new key exists.
32+
Function BotloftAdoptOldFolder
33+
Push $0
34+
ReadRegStr $0 SHCTX "${BOTLOFT_FOLDER_KEY}" ""
35+
StrCmp $0 "" 0 botloft_adopt_done
36+
ReadRegStr $0 SHCTX "${BOTLOFT_OLD_FOLDER_KEY}" ""
37+
StrCmp $0 "" botloft_adopt_done 0
38+
WriteRegStr SHCTX "${BOTLOFT_FOLDER_KEY}" "" $0
39+
; Only when the installer is still on its default folder: /D wins.
40+
StrCmp $INSTDIR "$LOCALAPPDATA\Botloft" 0 botloft_adopt_done
41+
StrCpy $INSTDIR $0
42+
botloft_adopt_done:
43+
Pop $0
44+
FunctionEnd
45+
46+
; Before the first page, so the pages and "uninstall before installing" see
47+
; the adopted folder.
48+
!define MUI_CUSTOMFUNCTION_GUIINIT BotloftAdoptOldFolder
49+
50+
!macro NSIS_HOOK_PREINSTALL
51+
; The quiet installer (/S) has no pages, so nothing adopted the folder yet.
52+
; The install section already set its output to the default folder.
53+
Call BotloftAdoptOldFolder
54+
SetOutPath $INSTDIR
55+
!macroend
56+
957
!macro NSIS_HOOK_POSTINSTALL
10-
; Installed apps shows Botloft as the publisher. `bundle.publisher` stays
11-
; unset on purpose: Tauri also names the key that keeps the install folder
12-
; after it (Software\<publisher>\Botloft), so a new name would make the
13-
; first update of an existing install run the old uninstaller without its
14-
; folder.
15-
WriteRegStr SHCTX "${UNINSTKEY}" "Publisher" "Botloft"
58+
; The folder is under the new name now; the old key goes.
59+
DeleteRegKey SHCTX "${BOTLOFT_OLD_FOLDER_KEY}"
60+
DeleteRegKey /ifempty SHCTX "${BOTLOFT_OLD_PUBLISHER_KEY}"
1661
!macroend
1762

1863
!macro NSIS_HOOK_PREUNINSTALL
1964
; A real uninstall stops the daemon and removes its scheduled task and
20-
; binary. Bots and data stay in %LOCALAPPDATA%\Botloft. An update runs
21-
; this uninstaller too, with /UPDATE: then the daemon keeps running.
65+
; binary. Bots and data stay in %LOCALAPPDATA%\Botloft. An uninstaller run
66+
; with /UPDATE leaves the daemon running.
2267
${If} $UpdateMode <> 1
2368
nsExec::Exec '"$INSTDIR\botloftd.exe" service uninstall'
2469
Pop $0

‎app/src/dev/seedBrowser.ts‎

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
// Dev only: Scout searching in its browser in the fake preview, so the
2-
// browser panel has something live to show. The "page" is drawn on a
3-
// canvas and sent as JPEG frames, like the daemon's screencast.
2+
// browser panel has something live to show: three tabs, the search in the
3+
// active one. The "page" is drawn on a canvas, the size the panel asks for,
4+
// and sent as JPEG frames, like the daemon's screencast.
45

56
import type { FakeBotloft } from "../lib/fake";
7+
import type { PageSize } from "../lib/fakePages";
68
import type { BotId } from "../lib/protocol.gen";
9+
import { activeTab, drawPage } from "./seedPage";
710

8-
const W = 1280;
9-
const H = 800;
11+
const SITE = "https://recipes.example/";
12+
13+
const RECIPES = ["Classic sourdough loaf", "Starter in 5 days", "No-knead rye", "Focaccia"];
1014

1115
interface Scene {
1216
url: string;
@@ -15,16 +19,16 @@ interface Scene {
1519
results: boolean;
1620
}
1721

18-
function draw(scene: Scene): string {
22+
function draw(scene: Scene, size: PageSize): string {
1923
const canvas = document.createElement("canvas");
20-
canvas.width = W;
21-
canvas.height = H;
24+
canvas.width = size.width;
25+
canvas.height = size.height;
2226
const g = canvas.getContext("2d");
2327
if (!g) {
2428
return "";
2529
}
2630
g.fillStyle = "#ffffff";
27-
g.fillRect(0, 0, W, H);
31+
g.fillRect(0, 0, size.width, size.height);
2832
g.fillStyle = "#1f2937";
2933
g.font = "600 34px system-ui, sans-serif";
3034
g.fillText(scene.results ? "Results" : "Find a recipe", 120, 150);
@@ -43,15 +47,12 @@ function draw(scene: Scene): string {
4347
g.fillStyle = "#ffffff";
4448
g.fillText("Search", 938, 226);
4549
if (scene.results) {
46-
for (let row = 0; row < 4; row++) {
50+
// A taller page shows more of the list.
51+
for (let row = 0; 300 + row * 110 < size.height - 60; row++) {
4752
const y = 300 + row * 110;
4853
g.fillStyle = "#1d4ed8";
4954
g.font = "600 24px system-ui, sans-serif";
50-
g.fillText(
51-
["Classic sourdough loaf", "Starter in 5 days", "No-knead rye", "Focaccia"][row] ?? "",
52-
120,
53-
y,
54-
);
55+
g.fillText(RECIPES[row % RECIPES.length] ?? "", 120, y);
5556
g.fillStyle = "#e5e7eb";
5657
g.fillRect(120, y + 18, 820, 14);
5758
g.fillRect(120, y + 42, 560, 14);
@@ -60,10 +61,10 @@ function draw(scene: Scene): string {
6061
return canvas.toDataURL("image/jpeg", 0.7).split(",")[1] ?? "";
6162
}
6263

63-
/** Scout searches for a recipe, over and over. */
64+
/** Scout searches for a recipe, over and over, while the browser is its. */
6465
export function seedBrowser(fake: FakeBotloft, scout: BotId): void {
6566
const home: Scene = {
66-
url: "https://recipes.example/",
67+
url: SITE,
6768
title: "Recipes",
6869
query: "",
6970
results: false,
@@ -116,15 +117,25 @@ export function seedBrowser(fake: FakeBotloft, scout: BotId): void {
116117
);
117118
let scene = home;
118119
let step = 0;
119-
fake.browser.open(scout, home.url, home.title);
120-
fake.browser.frame(scout, draw(scene));
120+
// Two pages it opened on the way, then the one it works in.
121+
fake.browser.open(scout, "https://bakers.example/forum/hydration", "Hydration tips · Bakers");
122+
fake.browser.openTab(scout, "https://mill.example/flour/rye", "Rye flour · The Mill");
123+
fake.browser.openTab(scout, home.url, home.title);
124+
fake.browser.paint(scout, (size) => {
125+
const tab = activeTab(fake, scout);
126+
return tab?.url.startsWith(SITE) ? draw(scene, size) : drawPage(tab, size);
127+
});
121128
setInterval(() => {
129+
// In the owner's hands, the bot waits.
130+
if (fake.browser.state(scout).control === "owner") {
131+
return;
132+
}
122133
const next = steps[step % steps.length];
123134
step += 1;
124135
if (next) {
125136
scene = next(scene);
126137
fake.browser.open(scout, scene.url, scene.title);
127-
fake.browser.frame(scout, draw(scene));
138+
fake.browser.repaint(scout);
128139
}
129140
}, 2200);
130141
}

‎app/src/dev/seedHands.ts‎

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
// the owner's clicks and keys, so taking the browser can be tried end to end.
44

55
import type { FakeBotloft } from "../lib/fake";
6+
import type { PageSize } from "../lib/fakePages";
67
import type { BrowserInput, CrewId } from "../lib/protocol.gen";
8+
import { activeTab, drawPage } from "./seedPage";
9+
10+
const SITE = "https://flour.example/";
711

8-
const W = 1280;
9-
const H = 800;
1012
const FIELDS = { user: 300, password: 390 } as const;
1113
type Field = keyof typeof FIELDS;
1214

@@ -17,16 +19,16 @@ interface Form {
1719
signedIn: boolean;
1820
}
1921

20-
function draw(form: Form): string {
22+
function draw(form: Form, size: PageSize): string {
2123
const canvas = document.createElement("canvas");
22-
canvas.width = W;
23-
canvas.height = H;
24+
canvas.width = size.width;
25+
canvas.height = size.height;
2426
const g = canvas.getContext("2d");
2527
if (!g) {
2628
return "";
2729
}
2830
g.fillStyle = "#f7f4ee";
29-
g.fillRect(0, 0, W, H);
31+
g.fillRect(0, 0, size.width, size.height);
3032
g.fillStyle = "#3b2a1a";
3133
g.font = "600 36px Georgia, serif";
3234
if (form.signedIn) {
@@ -90,21 +92,24 @@ export function seedHands(fake: FakeBotloft, crewId: CrewId): void {
9092
const clerk = fake.addBot(crewId, "Clerk", "Places the bakery's orders");
9193
fake.setBotState(clerk.id, "needs_approval", 2);
9294
const form: Form = { user: "", password: "", field: null, signedIn: false };
93-
const login = "https://flour.example/login";
95+
const login = `${SITE}login`;
96+
const onSite = () => activeTab(fake, clerk.id)?.url.startsWith(SITE) ?? false;
9497
fake.browser.open(clerk.id, login, "Sign in · Flour Co.");
95-
fake.browser.frame(clerk.id, draw(form));
98+
fake.browser.paint(clerk.id, (size) =>
99+
onSite() ? draw(form, size) : drawPage(activeTab(fake, clerk.id), size),
100+
);
96101
fake.chat.tool(clerk.id, "mcp__botloft__browser_open", { summary: login, status: "done" });
97102
const task = "Sign in to Flour Co. with the bakery's account";
98103
fake.chat.tool(clerk.id, "mcp__botloft__browser_ask_owner", { summary: task, status: "running" });
99104
fake.browser.ask(clerk.id, task);
100105
fake.browser.onInput(clerk.id, (input) => {
101-
if (form.signedIn) {
106+
if (form.signedIn || !onSite()) {
102107
return;
103108
}
104109
if (handle(form, input)) {
105110
form.signedIn = true;
106-
fake.browser.open(clerk.id, "https://flour.example/orders", "Orders · Flour Co.");
111+
fake.browser.open(clerk.id, `${SITE}orders`, "Orders · Flour Co.");
107112
}
108-
fake.browser.frame(clerk.id, draw(form));
113+
fake.browser.repaint(clerk.id);
109114
});
110115
}

‎app/src/dev/seedPage.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Dev only: any page of the fake preview's browsers that no seed draws
2+
// itself, such as a tab in the background or an address the owner typed.
3+
// A heading with the tab's title, its site, and lines of grey "text".
4+
5+
import type { FakeBotloft } from "../lib/fake";
6+
import type { PageSize } from "../lib/fakePages";
7+
import type { BotId, BrowserTab } from "../lib/protocol.gen";
8+
9+
/** The bot's active tab, if its browser has any. */
10+
export function activeTab(fake: FakeBotloft, botId: BotId): BrowserTab | null {
11+
return fake.browser.state(botId).tabs.find((tab) => tab.active) ?? null;
12+
}
13+
14+
export function drawPage(tab: BrowserTab | null, size: PageSize): string {
15+
const canvas = document.createElement("canvas");
16+
canvas.width = size.width;
17+
canvas.height = size.height;
18+
const g = canvas.getContext("2d");
19+
if (!g) {
20+
return "";
21+
}
22+
g.fillStyle = "#ffffff";
23+
g.fillRect(0, 0, size.width, size.height);
24+
if (tab && tab.url !== "about:blank") {
25+
g.fillStyle = "#111827";
26+
g.font = "600 40px Georgia, serif";
27+
g.fillText(tab.title || tab.url, 120, 160);
28+
g.fillStyle = "#6b7280";
29+
g.font = "20px system-ui, sans-serif";
30+
g.fillText(tab.url, 120, 204);
31+
g.fillStyle = "#e5e7eb";
32+
for (let y = 270; y < size.height - 80; y += 34) {
33+
g.fillRect(120, y, y % 5 === 0 ? 640 : 960, 14);
34+
}
35+
}
36+
return canvas.toDataURL("image/jpeg", 0.7).split(",")[1] ?? "";
37+
}

0 commit comments

Comments
 (0)