Skip to content

Commit 6fc5b97

Browse files
authored
fix: improve external component permission guidance
Classify external component permission failures and show the elevated startup action when administrator rights are likely required. Also ensure Core creates the SQLite database file before startup migrations so clean first-run native E2E can reach the UI.
1 parent 1f7fc63 commit 6fc5b97

9 files changed

Lines changed: 306 additions & 28 deletions

File tree

core/src/infrastructure/database/db.rs

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::path::{Path, PathBuf};
22
use std::sync::OnceLock;
33

4-
use sqlx::sqlite::SqlitePool;
4+
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool};
55

66
/// Process-wide database location. Set once at App startup via [`init`]
77
/// (typically `<AppData>/<bundle-id>/hv-database.db`). Core has no way
@@ -41,12 +41,52 @@ fn db_path() -> &'static Path {
4141
/// is intentionally out of scope for #1407 — see "Out of Scope" in the
4242
/// issue ("Schema redesigns or migrations.").
4343
pub async fn get_pool() -> Result<SqlitePool, sqlx::Error> {
44-
let path = db_path();
45-
if let Some(parent) = path.parent() {
44+
open_pool(db_path()).await
45+
}
46+
47+
async fn open_pool(path: &Path) -> Result<SqlitePool, sqlx::Error> {
48+
ensure_parent_dir(path).await?;
49+
let options = SqliteConnectOptions::new()
50+
.filename(path)
51+
.create_if_missing(true);
52+
SqlitePool::connect_with(options).await
53+
}
54+
55+
async fn ensure_parent_dir(path: &Path) -> Result<(), sqlx::Error> {
56+
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
4657
tokio::fs::create_dir_all(parent)
4758
.await
4859
.map_err(sqlx::Error::Io)?;
4960
}
50-
let database_url = format!("sqlite:{}", path.to_string_lossy());
51-
SqlitePool::connect(&database_url).await
61+
Ok(())
62+
}
63+
64+
#[cfg(test)]
65+
mod tests {
66+
use super::*;
67+
use tempfile::TempDir;
68+
69+
#[tokio::test]
70+
async fn open_pool_creates_missing_database_file() {
71+
let dir = TempDir::new().unwrap();
72+
let db_path = dir.path().join("missing").join("hv-database.db");
73+
74+
assert!(!db_path.exists());
75+
76+
let pool = open_pool(&db_path).await.unwrap();
77+
sqlx::query("CREATE TABLE smoke (id INTEGER PRIMARY KEY)")
78+
.execute(&pool)
79+
.await
80+
.unwrap();
81+
pool.close().await;
82+
83+
assert!(db_path.exists());
84+
}
85+
86+
#[tokio::test]
87+
async fn ensure_parent_dir_allows_filename_only_database_paths() {
88+
ensure_parent_dir(Path::new("hv-database.db"))
89+
.await
90+
.unwrap();
91+
}
5292
}

core/src/models/external_component_guidance.rs

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,24 @@ impl ExternalComponentGuidanceCandidate {
8989
pub fn classify_external_component_reason(detail: &str) -> ExternalComponentReasonKind {
9090
let normalized = detail.to_ascii_lowercase();
9191

92-
if normalized.contains("permission denied")
93-
|| normalized.contains("access is denied")
94-
|| normalized.contains("operation not permitted")
95-
|| normalized.contains("administrator")
92+
let permission_patterns = [
93+
"permission denied",
94+
"access denied",
95+
"access is denied",
96+
"operation not permitted",
97+
"administrator",
98+
"e_accessdenied",
99+
"0x80070005",
100+
];
101+
let windows_permission_patterns = ["os error 5"];
102+
103+
if permission_patterns
104+
.iter()
105+
.any(|pattern| normalized.contains(pattern))
106+
|| (cfg!(target_os = "windows")
107+
&& windows_permission_patterns
108+
.iter()
109+
.any(|pattern| normalized.contains(pattern)))
96110
{
97111
return ExternalComponentReasonKind::Permission;
98112
}
@@ -143,4 +157,34 @@ mod tests {
143157
ExternalComponentReasonKind::Missing
144158
);
145159
}
160+
161+
#[test]
162+
fn classify_external_component_reason_treats_access_denied_as_permission() {
163+
assert_eq!(
164+
classify_external_component_reason("CreateFile failed: access denied"),
165+
ExternalComponentReasonKind::Permission
166+
);
167+
}
168+
169+
#[test]
170+
fn classify_external_component_reason_treats_hresult_as_permission() {
171+
assert_eq!(
172+
classify_external_component_reason("pawnio_open failed: 0x80070005"),
173+
ExternalComponentReasonKind::Permission
174+
);
175+
}
176+
177+
#[test]
178+
fn classify_external_component_reason_treats_os_error_5_as_windows_only() {
179+
let expected = if cfg!(target_os = "windows") {
180+
ExternalComponentReasonKind::Permission
181+
} else {
182+
ExternalComponentReasonKind::Failed
183+
};
184+
185+
assert_eq!(
186+
classify_external_component_reason("open failed: os error 5"),
187+
expected
188+
);
189+
}
146190
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { cleanup, render, screen } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4+
import "@/lib/i18n";
5+
import type { ExternalComponentGuidanceCandidate } from "@/rspc/bindings";
6+
import { ExternalComponentGuidanceDialog } from "./ExternalComponentGuidanceDialog";
7+
8+
const mocks = vi.hoisted(() => ({
9+
acknowledgeExternalComponentGuidanceKey: vi.fn(),
10+
deferExternalComponentGuidanceForSession: vi.fn(),
11+
error: vi.fn(),
12+
getExternalComponentGuidanceCandidates: vi.fn(),
13+
openURL: vi.fn(),
14+
platform: vi.fn(() => "windows"),
15+
setElevatedStartupMode: vi.fn(),
16+
}));
17+
18+
vi.mock("@tauri-apps/plugin-os", () => ({
19+
platform: mocks.platform,
20+
}));
21+
22+
vi.mock("@/hooks/useTauriDialog", () => ({
23+
useTauriDialog: () => ({
24+
error: mocks.error,
25+
}),
26+
}));
27+
28+
vi.mock("@/lib/openUrl", () => ({
29+
openURL: mocks.openURL,
30+
}));
31+
32+
vi.mock("@/rspc/bindings", () => ({
33+
commands: {
34+
acknowledgeExternalComponentGuidanceKey:
35+
mocks.acknowledgeExternalComponentGuidanceKey,
36+
deferExternalComponentGuidanceForSession:
37+
mocks.deferExternalComponentGuidanceForSession,
38+
getExternalComponentGuidanceCandidates:
39+
mocks.getExternalComponentGuidanceCandidates,
40+
setElevatedStartupMode: mocks.setElevatedStartupMode,
41+
},
42+
}));
43+
44+
const candidate = (
45+
overrides: Partial<ExternalComponentGuidanceCandidate> = {},
46+
): ExternalComponentGuidanceCandidate => ({
47+
key: "pawnio:cpu-package-temperature:v1",
48+
component: "pawnio",
49+
usage: "cpuPackageTemperature",
50+
reasonKind: "permission",
51+
missingSignals: ["cpu-temperature"],
52+
affectedDeviceCount: null,
53+
diagnosticDetail: "pawnio_open failed: 0x80070005",
54+
...overrides,
55+
});
56+
57+
describe("ExternalComponentGuidanceDialog", () => {
58+
beforeEach(() => {
59+
vi.clearAllMocks();
60+
mocks.platform.mockReturnValue("windows");
61+
mocks.getExternalComponentGuidanceCandidates.mockResolvedValue({
62+
status: "ok",
63+
data: [candidate()],
64+
});
65+
mocks.setElevatedStartupMode.mockResolvedValue({
66+
status: "ok",
67+
data: null,
68+
});
69+
});
70+
71+
afterEach(() => {
72+
cleanup();
73+
});
74+
75+
it("shows the elevated startup action instead of details for Windows permission guidance", async () => {
76+
const user = userEvent.setup();
77+
78+
render(<ExternalComponentGuidanceDialog displayTarget="dashboard" />);
79+
80+
await user.click(
81+
await screen.findByRole("button", {
82+
name: "Restart as administrator",
83+
}),
84+
);
85+
86+
expect(screen.queryByRole("button", { name: "Open details" })).toBeNull();
87+
expect(mocks.setElevatedStartupMode).toHaveBeenCalledWith(true);
88+
expect(mocks.openURL).not.toHaveBeenCalled();
89+
});
90+
91+
it("keeps the details action for permission guidance outside Windows", async () => {
92+
const user = userEvent.setup();
93+
mocks.platform.mockReturnValue("linux");
94+
95+
render(<ExternalComponentGuidanceDialog displayTarget="dashboard" />);
96+
97+
await user.click(
98+
await screen.findByRole("button", {
99+
name: "Open details",
100+
}),
101+
);
102+
103+
expect(
104+
screen.queryByRole("button", { name: "Restart as administrator" }),
105+
).toBeNull();
106+
expect(mocks.openURL).toHaveBeenCalledTimes(1);
107+
expect(mocks.setElevatedStartupMode).not.toHaveBeenCalled();
108+
});
109+
});

src/components/shared/ExternalComponentGuidanceDialog.tsx

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { ChevronDownIcon, ExternalLinkIcon, EyeOffIcon } from "lucide-react";
1+
import { platform } from "@tauri-apps/plugin-os";
2+
import {
3+
ChevronDownIcon,
4+
ExternalLinkIcon,
5+
EyeOffIcon,
6+
ShieldIcon,
7+
} from "lucide-react";
28
import { useEffect, useMemo, useState } from "react";
39
import { useTranslation } from "react-i18next";
410
import {
@@ -23,6 +29,7 @@ import { commands } from "@/rspc/bindings";
2329
import { isError } from "@/types/result";
2430
import type { SelectedDisplayType } from "@/types/ui";
2531
import {
32+
externalComponentGuidanceActionKey,
2633
externalComponentGuidanceCopyKey,
2734
externalComponentGuidanceDocsUrl,
2835
externalComponentGuidanceViewForDisplayTarget,
@@ -42,6 +49,8 @@ export const ExternalComponentGuidanceDialog = ({
4249
const [candidates, setCandidates] = useState<
4350
ExternalComponentGuidanceCandidate[]
4451
>([]);
52+
const [isEnablingElevatedStartupMode, setIsEnablingElevatedStartupMode] =
53+
useState(false);
4554

4655
const view = useMemo(
4756
() => externalComponentGuidanceViewForDisplayTarget(displayTarget),
@@ -90,6 +99,11 @@ export const ExternalComponentGuidanceDialog = ({
9099
const copyKey = candidate
91100
? externalComponentGuidanceCopyKey(candidate)
92101
: null;
102+
const actionKey = candidate
103+
? externalComponentGuidanceActionKey(candidate)
104+
: null;
105+
const shouldShowElevatedStartupAction =
106+
candidate?.reasonKind === "permission" && platform() === "windows";
93107

94108
const removeCandidate = (key: string) => {
95109
setCandidates((current) => current.filter((item) => item.key !== key));
@@ -157,7 +171,31 @@ export const ExternalComponentGuidanceDialog = ({
157171
}
158172
};
159173

160-
if (!candidate || !copyKey) {
174+
const handleEnableElevatedStartupMode = async () => {
175+
if (!candidate) return;
176+
177+
setIsEnablingElevatedStartupMode(true);
178+
try {
179+
const result = await commands.setElevatedStartupMode(true);
180+
if (isError(result)) {
181+
console.error(
182+
"Failed to enable elevated startup mode from external component guidance:",
183+
result.error,
184+
);
185+
await error(t("externalComponentGuidance.errors.elevatedStartupMode"));
186+
}
187+
} catch (err) {
188+
console.error(
189+
"Failed to enable elevated startup mode from external component guidance:",
190+
err,
191+
);
192+
await error(t("externalComponentGuidance.errors.elevatedStartupMode"));
193+
} finally {
194+
setIsEnablingElevatedStartupMode(false);
195+
}
196+
};
197+
198+
if (!candidate || !copyKey || !actionKey) {
161199
return null;
162200
}
163201

@@ -185,17 +223,29 @@ export const ExternalComponentGuidanceDialog = ({
185223
<GuidanceRow
186224
label={t("externalComponentGuidance.labels.action")}
187225
value={t(
188-
`externalComponentGuidance.candidates.${copyKey}.action`,
226+
`externalComponentGuidance.candidates.${copyKey}.${actionKey}`,
189227
)}
190228
/>
191229
</div>
192230
</AlertDialogDescription>
193231
</AlertDialogHeader>
194232
<AlertDialogFooter className="gap-2">
195-
<Button onClick={handleOpenDetails} type="button" variant="outline">
196-
<ExternalLinkIcon className="size-4" />
197-
{t("externalComponentGuidance.actions.openDetails")}
198-
</Button>
233+
{shouldShowElevatedStartupAction ? (
234+
<Button
235+
disabled={isEnablingElevatedStartupMode}
236+
onClick={() => void handleEnableElevatedStartupMode()}
237+
type="button"
238+
variant="outline"
239+
>
240+
<ShieldIcon className="size-4" />
241+
{t("externalComponentGuidance.actions.enableElevatedStartupMode")}
242+
</Button>
243+
) : (
244+
<Button onClick={handleOpenDetails} type="button" variant="outline">
245+
<ExternalLinkIcon className="size-4" />
246+
{t("externalComponentGuidance.actions.openDetails")}
247+
</Button>
248+
)}
199249
<DropdownMenu modal={false}>
200250
<DropdownMenuTrigger asChild>
201251
<Button type="button" variant="secondary">

src/components/shared/externalComponentGuidance.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from "vitest";
22
import type { ExternalComponentGuidanceCandidate } from "@/rspc/bindings";
33
import {
4+
externalComponentGuidanceActionKey,
45
externalComponentGuidanceCopyKey,
56
externalComponentGuidanceDocsBaseUrl,
67
externalComponentGuidanceDocsUrl,
@@ -51,6 +52,18 @@ describe("externalComponentGuidance", () => {
5152
expect(externalComponentGuidanceDocsUrl(smartctl)).toContain("#smartctl");
5253
});
5354

55+
it("uses permission action copy for permission failures", () => {
56+
const missing = candidate("pawnio:cpu-package-temperature:v1");
57+
const permission = candidate("pawnio:cpu-package-temperature:v1", {
58+
reasonKind: "permission",
59+
});
60+
61+
expect(externalComponentGuidanceActionKey(missing)).toBe("action");
62+
expect(externalComponentGuidanceActionKey(permission)).toBe(
63+
"permissionAction",
64+
);
65+
});
66+
5467
it("uses Japanese docs URLs for Japanese UI languages", () => {
5568
const pawnio = candidate("pawnio:cpu-package-temperature:v1");
5669

src/components/shared/externalComponentGuidance.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ type ExternalComponentGuidanceCopyKey =
2020
| "smartctlStorageHealth"
2121
| "generic";
2222

23+
type ExternalComponentGuidanceActionKey = "action" | "permissionAction";
24+
2325
const EXTERNAL_COMPONENT_COPY_KEYS: Record<
2426
string,
2527
ExternalComponentGuidanceCopyKey
@@ -46,6 +48,11 @@ export const externalComponentGuidanceCopyKey = (
4648
): ExternalComponentGuidanceCopyKey =>
4749
EXTERNAL_COMPONENT_COPY_KEYS[candidate.key] ?? "generic";
4850

51+
export const externalComponentGuidanceActionKey = (
52+
candidate: ExternalComponentGuidanceCandidate,
53+
): ExternalComponentGuidanceActionKey =>
54+
candidate.reasonKind === "permission" ? "permissionAction" : "action";
55+
4956
export const externalComponentGuidanceDocsBaseUrl = (
5057
language?: string | null,
5158
) =>

0 commit comments

Comments
 (0)