feat: add permission consent prompts, drop permitted capability mask
This commit is contained in:
1 parent
c54181103f
commit
ae5e84592d
25 files changed
+1632
-94
No files matched your search
@@ -8,6 +8,7 @@
|
||||
#include <montauk/string.h>
|
||||
#include <montauk/config.h>
|
||||
#include <montauk/capabilities.h>
|
||||
#include <montauk/consent.h>
|
||||
#include <gui/mtk.hpp>
|
||||
#include <gui/mtk/settings.hpp>
|
||||
#include <gui/standalone.hpp>
|
||||
@@ -30,6 +31,7 @@ static constexpr int GRID_ROW_H = 22;
|
||||
static constexpr int GRID_COL_W = 86;
|
||||
static constexpr int MAX_GRANTS = 64;
|
||||
static constexpr int MAX_ISSUES = 4;
|
||||
static constexpr int MAX_DECISIONS = 64;
|
||||
|
||||
static constexpr int CAP_COUNT =
|
||||
(int)(sizeof(montauk::caps::NAMES) / sizeof(montauk::caps::NAMES[0]));
|
||||
@@ -37,12 +39,14 @@ static constexpr int CAP_COUNT =
|
||||
enum Tab {
|
||||
TAB_PROGRAMS = 0,
|
||||
TAB_CAPS = 1,
|
||||
TAB_COUNT = 2,
|
||||
TAB_CONSENT = 2,
|
||||
TAB_COUNT = 3,
|
||||
};
|
||||
|
||||
static const char* const kTabLabels[TAB_COUNT] = {
|
||||
"Programs",
|
||||
"Capabilities",
|
||||
"Consent",
|
||||
};
|
||||
|
||||
// What each capability gates, in the order of montauk::caps::NAMES.
|
||||
@@ -68,16 +72,15 @@ static const char* const kCapDescriptions[CAP_COUNT] = {
|
||||
enum Field {
|
||||
FIELD_EFFECTIVE = 0,
|
||||
FIELD_DELEGABLE = 1,
|
||||
FIELD_PERMITTED = 2,
|
||||
FIELD_COUNT = 3,
|
||||
FIELD_COUNT = 2,
|
||||
};
|
||||
|
||||
static const char* const kFieldKeys[FIELD_COUNT] = {
|
||||
"effective", "delegable", "permitted",
|
||||
"effective", "delegable",
|
||||
};
|
||||
|
||||
static const char* const kFieldLabels[FIELD_COUNT] = {
|
||||
"Effective", "Delegable", "Permitted",
|
||||
"Effective", "Delegable",
|
||||
};
|
||||
|
||||
struct Grant {
|
||||
@@ -104,6 +107,11 @@ static Color g_accent = colors::ACCENT;
|
||||
static bool g_is_admin = false;
|
||||
static int g_selected_grant = 0;
|
||||
static int g_selected_cap = 0;
|
||||
static int g_selected_decision = 0;
|
||||
|
||||
// Answers given to consent prompts (0:/config/consent.toml).
|
||||
static montauk::consent::Decision g_decisions[MAX_DECISIONS];
|
||||
static int g_decision_count = 0;
|
||||
static int g_list_scroll = 0;
|
||||
static bool g_list_sb_dragging = false;
|
||||
static int g_list_sb_drag_offset = 0;
|
||||
@@ -214,14 +222,6 @@ static void load_grants() {
|
||||
|
||||
for (int i = 0; i < g_grant_count; i++) {
|
||||
Grant& g = g_grants[i];
|
||||
uint64_t* m = g.mask;
|
||||
|
||||
// Same rule as caps::lookup(): an undeclared permitted set is exactly
|
||||
// what the grant can use or pass on.
|
||||
if (!g.declared[FIELD_PERMITTED] || m[FIELD_PERMITTED] == 0)
|
||||
m[FIELD_PERMITTED] = m[FIELD_EFFECTIVE] | m[FIELD_DELEGABLE];
|
||||
else if ((m[FIELD_EFFECTIVE] | m[FIELD_DELEGABLE]) & ~m[FIELD_PERMITTED])
|
||||
add_issue(g, "Effective/delegable exceed permitted; the excess is dropped at launch");
|
||||
|
||||
if (g.path[0] == '\0') {
|
||||
add_issue(g, "No path; this grant never applies");
|
||||
@@ -231,6 +231,8 @@ static void load_grants() {
|
||||
g.binary_present = bh >= 0;
|
||||
if (bh >= 0) montauk::close(bh);
|
||||
if (!g.binary_present) add_issue(g, "Binary not found on this system");
|
||||
if (!montauk::caps::is_installed_path(g.path))
|
||||
add_issue(g, "Never applies: not under 0:/os or 0:/apps, so it runs with no capabilities");
|
||||
|
||||
for (int j = 0; j < i; j++) {
|
||||
if (montauk::streq(g_grants[j].path, g.path)) {
|
||||
@@ -244,6 +246,25 @@ static void load_grants() {
|
||||
if (g_selected_grant >= g_grant_count) g_selected_grant = 0;
|
||||
}
|
||||
|
||||
static void load_decisions() {
|
||||
g_decision_count = montauk::consent::load_all(g_decisions, MAX_DECISIONS);
|
||||
if (g_selected_decision >= g_decision_count)
|
||||
g_selected_decision = g_decision_count > 0 ? g_decision_count - 1 : 0;
|
||||
}
|
||||
|
||||
static const char* base_name(const char* path) {
|
||||
const char* base = path;
|
||||
for (const char* p = path; *p; p++)
|
||||
if (*p == '/' || *p == ':') base = p + 1;
|
||||
return base;
|
||||
}
|
||||
|
||||
static int cap_index(uint64_t bit) {
|
||||
for (int i = 0; i < CAP_COUNT; i++)
|
||||
if (montauk::caps::NAMES[i].bit == bit) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
// ==== Layout ====
|
||||
|
||||
static mtk::Theme app_theme() {
|
||||
@@ -271,7 +292,15 @@ static Rect list_rect() {
|
||||
}
|
||||
|
||||
static int list_count() {
|
||||
return g_tab == TAB_PROGRAMS ? g_grant_count : CAP_COUNT;
|
||||
if (g_tab == TAB_PROGRAMS) return g_grant_count;
|
||||
if (g_tab == TAB_CONSENT) return g_decision_count;
|
||||
return CAP_COUNT;
|
||||
}
|
||||
|
||||
static int* selected_for_tab() {
|
||||
if (g_tab == TAB_PROGRAMS) return &g_selected_grant;
|
||||
if (g_tab == TAB_CONSENT) return &g_selected_decision;
|
||||
return &g_selected_cap;
|
||||
}
|
||||
|
||||
static int list_visible_rows() {
|
||||
@@ -322,6 +351,15 @@ static Rect reload_button() {
|
||||
return {foot.x + foot.w - PAD - BUTTON_W, foot.y + 7, BUTTON_W, 30};
|
||||
}
|
||||
|
||||
static Rect forget_button() {
|
||||
Rect reload = reload_button();
|
||||
return {reload.x - 8 - BUTTON_W, reload.y, BUTTON_W, reload.h};
|
||||
}
|
||||
|
||||
static bool forget_visible() {
|
||||
return g_is_admin && g_tab == TAB_CONSENT && g_decision_count > 0;
|
||||
}
|
||||
|
||||
static bool mouse_in(const Rect& rect) {
|
||||
return rect.contains(g_mouse_x, g_mouse_y);
|
||||
}
|
||||
@@ -382,17 +420,24 @@ static void draw_list(Canvas& c, const mtk::Theme& theme) {
|
||||
int index = g_list_scroll + row;
|
||||
if (index >= count) break;
|
||||
Rect r = list_row_rect(row);
|
||||
bool selected = index == (g_tab == TAB_PROGRAMS ? g_selected_grant : g_selected_cap);
|
||||
bool selected = index == *selected_for_tab();
|
||||
mtk::draw_list_row(c, r, selected, false, theme);
|
||||
if (!selected && mouse_in(r))
|
||||
c.fill_rounded_rect(r.x, r.y, r.w, r.h, theme.radius_md, theme.surface_hover);
|
||||
|
||||
const char* label = g_tab == TAB_PROGRAMS
|
||||
? g_grants[index].id : montauk::caps::NAMES[index].name;
|
||||
const char* label = g_tab == TAB_PROGRAMS ? g_grants[index].id
|
||||
: g_tab == TAB_CONSENT ? base_name(g_decisions[index].path)
|
||||
: montauk::caps::NAMES[index].name;
|
||||
Color fg = selected ? theme.accent_fg : theme.text;
|
||||
int ty = r.y + (r.h - fh) / 2;
|
||||
draw_text_fit(c, r.x + 10, ty, label, r.w - 34, fg);
|
||||
|
||||
if (g_tab == TAB_CONSENT) {
|
||||
Color dot = selected ? theme.accent_fg
|
||||
: g_decisions[index].allow ? theme.accent : theme.text_muted;
|
||||
fill_circle(c, r.x + r.w - 14, r.y + r.h / 2, 4, dot);
|
||||
}
|
||||
|
||||
if (g_tab == TAB_PROGRAMS && g_grants[index].issue_count > 0) {
|
||||
Color dot = selected ? theme.accent_fg : theme.danger;
|
||||
fill_circle(c, r.x + r.w - 14, r.y + r.h / 2, 4, dot);
|
||||
@@ -459,7 +504,6 @@ static void draw_programs_pane(Canvas& c, const mtk::Theme& theme) {
|
||||
for (int f = 0; f < FIELD_COUNT; f++) {
|
||||
const char* note = nullptr;
|
||||
if (g.uses_all[f]) note = "(all)";
|
||||
else if (f == FIELD_PERMITTED && !g.declared[f]) note = "(implied)";
|
||||
if (!note) continue;
|
||||
int cx = colX + f * GRID_COL_W + GRID_COL_W / 2;
|
||||
c.text(cx - text_width(note) / 2, y, note, theme.text_muted);
|
||||
@@ -468,7 +512,7 @@ static void draw_programs_pane(Canvas& c, const mtk::Theme& theme) {
|
||||
|
||||
for (int i = 0; i < CAP_COUNT; i++) {
|
||||
uint64_t bit = montauk::caps::NAMES[i].bit;
|
||||
bool any = (g.mask[FIELD_PERMITTED] & bit) != 0;
|
||||
bool any = ((g.mask[FIELD_EFFECTIVE] | g.mask[FIELD_DELEGABLE]) & bit) != 0;
|
||||
if (i % 2 == 1)
|
||||
c.fill_rect(x, y, w, GRID_ROW_H, theme.surface_alt);
|
||||
c.text(x + 6, y + (GRID_ROW_H - fh) / 2, montauk::caps::NAMES[i].name,
|
||||
@@ -541,6 +585,43 @@ static void draw_caps_pane(Canvas& c, const mtk::Theme& theme) {
|
||||
"MAY PASS IT ON (DELEGABLE)", FIELD_DELEGABLE, cap.bit, theme);
|
||||
}
|
||||
|
||||
static void draw_consent_pane(Canvas& c, const mtk::Theme& theme) {
|
||||
int x = pane_x();
|
||||
int w = pane_w();
|
||||
int fh = system_font_height();
|
||||
int y = TAB_H + PAD;
|
||||
|
||||
if (g_decision_count == 0) {
|
||||
c.text(x, y, "No consent decisions are stored.", theme.text_subtle);
|
||||
c.text(x, y + fh + 8, "Programs without a grant ask the first time they need", theme.text_muted);
|
||||
c.text(x, y + (fh + 8) * 2, "storage, network, time, display or log access.", theme.text_muted);
|
||||
return;
|
||||
}
|
||||
|
||||
const montauk::consent::Decision& d = g_decisions[g_selected_decision];
|
||||
c.text(x, y, base_name(d.path), theme.text);
|
||||
const char* state = d.allow ? "Allowed" : "Denied";
|
||||
c.text(x + w - text_width(state), y, state, d.allow ? theme.accent : theme.danger);
|
||||
y += fh + 6;
|
||||
draw_text_fit(c, x, y, d.path, w, theme.text_subtle);
|
||||
|
||||
y += fh + 14;
|
||||
mtk::draw_separator(c, x, y, w, theme);
|
||||
y += 14;
|
||||
|
||||
int ci = cap_index(d.capability);
|
||||
c.text(x, y, "CAPABILITY", theme.text_muted);
|
||||
y += fh + 6;
|
||||
c.text(x + 10, y, ci >= 0 ? montauk::caps::NAMES[ci].name : "?", theme.text);
|
||||
y += fh + 4;
|
||||
if (ci >= 0) draw_text_fit(c, x + 10, y, kCapDescriptions[ci], w - 10, theme.text_subtle);
|
||||
y += fh + 16;
|
||||
|
||||
c.text(x, y, "Forget makes the program ask again the next time it needs", theme.text_muted);
|
||||
y += fh + 4;
|
||||
c.text(x, y, "this. A copy already running keeps its access until it exits.", theme.text_muted);
|
||||
}
|
||||
|
||||
static void draw_locked(Canvas& c, const mtk::Theme& theme) {
|
||||
int fh = system_font_height();
|
||||
int y = TAB_H + 60;
|
||||
@@ -564,9 +645,13 @@ static void draw_footer(Canvas& c, const mtk::Theme& theme) {
|
||||
int flagged = 0;
|
||||
for (int i = 0; i < g_grant_count; i++)
|
||||
if (g_grants[i].issue_count > 0) flagged++;
|
||||
snprintf(summary, sizeof(summary), "%d grant%s%s, %d flagged - read-only view",
|
||||
g_grant_count, g_grant_count == 1 ? "" : "s",
|
||||
g_truncated ? " (list truncated)" : "", flagged);
|
||||
if (g_tab == TAB_CONSENT)
|
||||
snprintf(summary, sizeof(summary), "%d stored decision%s",
|
||||
g_decision_count, g_decision_count == 1 ? "" : "s");
|
||||
else
|
||||
snprintf(summary, sizeof(summary), "%d grant%s%s, %d flagged - read-only view",
|
||||
g_grant_count, g_grant_count == 1 ? "" : "s",
|
||||
g_truncated ? " (list truncated)" : "", flagged);
|
||||
}
|
||||
c.text(PAD, foot.y + (foot.h - system_font_height()) / 2, summary, theme.text_subtle);
|
||||
|
||||
@@ -575,6 +660,11 @@ static void draw_footer(Canvas& c, const mtk::Theme& theme) {
|
||||
mtk::draw_button(c, reload, "Reload", mtk::BUTTON_SECONDARY,
|
||||
mtk::widget_state(false, mouse_in(reload), true), theme);
|
||||
}
|
||||
if (forget_visible()) {
|
||||
Rect forget = forget_button();
|
||||
mtk::draw_button(c, forget, "Forget", mtk::BUTTON_DANGER,
|
||||
mtk::widget_state(false, mouse_in(forget), true), theme);
|
||||
}
|
||||
}
|
||||
|
||||
static void render() {
|
||||
@@ -586,6 +676,7 @@ static void render() {
|
||||
mtk::draw_tab_bar(c, tab_bar_rect(), kTabLabels, TAB_COUNT, (int)g_tab, theme);
|
||||
draw_list(c, theme);
|
||||
if (g_tab == TAB_PROGRAMS) draw_programs_pane(c, theme);
|
||||
else if (g_tab == TAB_CONSENT) draw_consent_pane(c, theme);
|
||||
else draw_caps_pane(c, theme);
|
||||
} else {
|
||||
draw_locked(c, theme);
|
||||
@@ -598,8 +689,25 @@ static void render() {
|
||||
|
||||
static void reload() {
|
||||
load_grants();
|
||||
load_decisions();
|
||||
clamp_list_scroll();
|
||||
set_status("Reloaded 0:/config/capabilities.toml");
|
||||
set_status(g_tab == TAB_CONSENT ? "Reloaded 0:/config/consent.toml"
|
||||
: "Reloaded 0:/config/capabilities.toml");
|
||||
}
|
||||
|
||||
static void forget_selected() {
|
||||
if (g_decision_count == 0) return;
|
||||
const montauk::consent::Decision& d = g_decisions[g_selected_decision];
|
||||
if (montauk::consent::forget(d.id) < 0) {
|
||||
set_status("Could not write 0:/config/consent.toml");
|
||||
return;
|
||||
}
|
||||
char msg[128];
|
||||
snprintf(msg, sizeof(msg), "Forgot %s for %s", montauk::consent::name_for(d.capability),
|
||||
base_name(d.path));
|
||||
load_decisions();
|
||||
clamp_list_scroll();
|
||||
set_status(msg);
|
||||
}
|
||||
|
||||
static void select_index(int index) {
|
||||
@@ -607,8 +715,7 @@ static void select_index(int index) {
|
||||
if (count <= 0) return;
|
||||
if (index < 0) index = 0;
|
||||
if (index >= count) index = count - 1;
|
||||
if (g_tab == TAB_PROGRAMS) g_selected_grant = index;
|
||||
else g_selected_cap = index;
|
||||
*selected_for_tab() = index;
|
||||
|
||||
int visible = list_visible_rows();
|
||||
if (index < g_list_scroll) g_list_scroll = index;
|
||||
@@ -619,7 +726,7 @@ static void select_index(int index) {
|
||||
static void switch_tab(Tab tab) {
|
||||
g_tab = tab;
|
||||
g_list_scroll = 0;
|
||||
select_index(tab == TAB_PROGRAMS ? g_selected_grant : g_selected_cap);
|
||||
select_index(*selected_for_tab());
|
||||
}
|
||||
|
||||
static void handle_mouse(const montauk::abi::WinEvent& ev) {
|
||||
@@ -675,6 +782,7 @@ static void handle_mouse(const montauk::abi::WinEvent& ev) {
|
||||
}
|
||||
}
|
||||
if (reload_button().contains(mx, my)) reload();
|
||||
if (forget_visible() && forget_button().contains(mx, my)) forget_selected();
|
||||
}
|
||||
|
||||
static bool handle_key(const montauk::abi::KeyEvent& key) {
|
||||
@@ -685,10 +793,10 @@ static bool handle_key(const montauk::abi::KeyEvent& key) {
|
||||
}
|
||||
if (!g_is_admin) return false;
|
||||
if (key.ascii == '\t') {
|
||||
switch_tab(g_tab == TAB_PROGRAMS ? TAB_CAPS : TAB_PROGRAMS);
|
||||
switch_tab((Tab)((g_tab + 1) % TAB_COUNT));
|
||||
return true;
|
||||
}
|
||||
int current = g_tab == TAB_PROGRAMS ? g_selected_grant : g_selected_cap;
|
||||
int current = *selected_for_tab();
|
||||
if (key.scancode == 0x48) { select_index(current - 1); return true; } // Up
|
||||
if (key.scancode == 0x50) { select_index(current + 1); return true; } // Down
|
||||
if (key.ascii == 'r' || key.ascii == 'R') {
|
||||
@@ -703,7 +811,10 @@ extern "C" void _start() {
|
||||
g_accent = mtk::load_system_accent();
|
||||
|
||||
g_is_admin = session_is_admin();
|
||||
if (g_is_admin) load_grants();
|
||||
if (g_is_admin) {
|
||||
load_grants();
|
||||
load_decisions();
|
||||
}
|
||||
|
||||
if (!g_win.create("Capabilities", WIN_W, WIN_H)) montauk::exit(1);
|
||||
render();
|
||||
|
||||
@@ -56,7 +56,7 @@ LDFLAGS := \
|
||||
|
||||
# ---- C++ source files ----
|
||||
|
||||
CORE_SRCS := main.cpp window.cpp panel.cpp wifi.cpp battery.cpp compose.cpp input.cpp dialogs.cpp launcher.cpp desktop_builtin.cpp desktop_catalog.cpp font_data.cpp stb_truetype_impl.cpp
|
||||
CORE_SRCS := main.cpp window.cpp panel.cpp wifi.cpp battery.cpp compose.cpp input.cpp dialogs.cpp launcher.cpp desktop_builtin.cpp desktop_catalog.cpp consent.cpp font_data.cpp stb_truetype_impl.cpp
|
||||
APP_SRCS := $(sort $(shell find apps -name '*.cpp' -print))
|
||||
SRCS := $(CORE_SRCS) $(APP_SRCS)
|
||||
OBJS := $(addprefix $(OBJDIR)/,$(SRCS:.cpp=.o))
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
/*
|
||||
* consent.cpp
|
||||
* Consent agent: answers the kernel's first-use capability requests from
|
||||
* stored decisions, or asks the user in a prompt window (docs/consent.md)
|
||||
* Copyright (c) 2026 Daniel Hammer
|
||||
*/
|
||||
|
||||
#include "desktop_internal.hpp"
|
||||
#include <gui/mtk.hpp>
|
||||
#include <montauk/consent.h>
|
||||
|
||||
// The prompt is an ordinary window inside the desktop, but an INTERNAL one:
|
||||
// its pixels live in the desktop's heap, not in a window-server surface, so
|
||||
// no app can map it (SYS_WINMAP) and only real input routed by the desktop
|
||||
// reaches it. What remains is clickjacking -- an app raising a window over
|
||||
// the prompt, or stealing focus, just as the user clicks. So Allow only
|
||||
// arms after the prompt has been focused and on top for ARM_DELAY_MS without
|
||||
// interruption; losing either restarts the delay. Don't Allow and closing
|
||||
// are always safe and never wait.
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int PROMPT_W = 460;
|
||||
constexpr int PAD = 20;
|
||||
constexpr int ICON = 32;
|
||||
constexpr int BTN_W = 120;
|
||||
constexpr int BTN_GAP = 12;
|
||||
constexpr int MAX_LINES = 6;
|
||||
constexpr int LINE_CHARS = 160;
|
||||
constexpr uint64_t ARM_DELAY_MS = 700;
|
||||
|
||||
struct ConsentPrompt {
|
||||
DesktopState* ds;
|
||||
montauk::abi::ConsentRequest req;
|
||||
char headline[MAX_LINES][LINE_CHARS];
|
||||
int headline_lines;
|
||||
char path[2][LINE_CHARS];
|
||||
int path_lines;
|
||||
uint64_t opened_at;
|
||||
uint64_t armed_since; // 0 while not both focused and on top
|
||||
bool armed;
|
||||
bool answered;
|
||||
bool hover_allow;
|
||||
bool hover_deny;
|
||||
};
|
||||
|
||||
bool g_registered = false;
|
||||
ConsentPrompt* g_open = nullptr; // one prompt at a time; the rest wait in the kernel
|
||||
Color g_icon_fill = {};
|
||||
|
||||
// Prompt icons, loaded on first use: one per capability plus the fallback.
|
||||
struct CachedIcon {
|
||||
const char* path;
|
||||
SvgIcon icon;
|
||||
};
|
||||
CachedIcon g_icons[8] = {};
|
||||
|
||||
const SvgIcon* icon_at(const char* path) {
|
||||
for (auto& c : g_icons) {
|
||||
if (c.path && montauk::streq(c.path, path)) return c.icon.pixels ? &c.icon : nullptr;
|
||||
if (c.path == nullptr) {
|
||||
c.path = path;
|
||||
c.icon = svg_load(path, ICON, ICON, g_icon_fill);
|
||||
return c.icon.pixels ? &c.icon : nullptr;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const SvgIcon* icon_for(uint64_t capability) {
|
||||
const SvgIcon* icon = icon_at(montauk::consent::icon_for(capability));
|
||||
return icon ? icon : icon_at(montauk::consent::DEFAULT_ICON);
|
||||
}
|
||||
|
||||
// ==== Text ====
|
||||
|
||||
// Greedy word wrap into fixed line buffers. A word wider than the line is
|
||||
// hard-broken so hostile input cannot overflow the layout.
|
||||
int wrap_text(const char* text, int max_w, char (*lines)[LINE_CHARS], int max_lines) {
|
||||
int count = 0;
|
||||
const char* p = text;
|
||||
while (*p && count < max_lines) {
|
||||
while (*p == ' ') p++;
|
||||
if (!*p) break;
|
||||
|
||||
char* line = lines[count];
|
||||
int len = 0;
|
||||
int last_break = -1;
|
||||
const char* resume = p;
|
||||
while (*p && len < LINE_CHARS - 1) {
|
||||
line[len] = *p;
|
||||
line[len + 1] = '\0';
|
||||
if (text_width(line) > max_w && len > 0) {
|
||||
line[len] = '\0';
|
||||
break;
|
||||
}
|
||||
if (*p == ' ') {
|
||||
last_break = len;
|
||||
resume = p + 1;
|
||||
}
|
||||
len++;
|
||||
p++;
|
||||
}
|
||||
line[len] = '\0';
|
||||
if (*p && *p != ' ' && last_break > 0) {
|
||||
// Back up to the last space so a word is not split.
|
||||
line[last_break] = '\0';
|
||||
p = resume;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
|
||||
if (*p && count == max_lines) {
|
||||
// Out of lines: mark the cut rather than hide it.
|
||||
char* last = lines[max_lines - 1];
|
||||
int len = montauk::slen(last);
|
||||
if (len > LINE_CHARS - 4) len = LINE_CHARS - 4;
|
||||
while (len > 0 && text_width(last) + text_width("...") > max_w) last[--len] = '\0';
|
||||
last[len] = '.'; last[len + 1] = '.'; last[len + 2] = '.'; last[len + 3] = '\0';
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// The file name is chosen by whoever put the binary there. Show it as
|
||||
// printable ASCII so control bytes cannot fake the prompt's layout.
|
||||
void make_printable(char* s) {
|
||||
for (; *s; s++) {
|
||||
if (*s < 0x20 || *s >= 0x7F) *s = '?';
|
||||
}
|
||||
}
|
||||
|
||||
// "0:/apps/foo/foo.elf" -> "foo" when no installed app claims the path.
|
||||
void app_name_for(const char* path, char* out, int cap) {
|
||||
for (int i = 0; i < menu_row_count; i++) {
|
||||
const MenuRow& row = menu_rows[i];
|
||||
if (row.external && montauk::streq(row.binary_path, path)) {
|
||||
montauk::strncpy(out, row.label, cap);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const char* base = path;
|
||||
for (const char* p = path; *p; p++) {
|
||||
if (*p == '/' || *p == ':') base = p + 1;
|
||||
}
|
||||
montauk::strncpy(out, base, cap);
|
||||
int len = montauk::slen(out);
|
||||
if (len > 4 && montauk::streq(out + len - 4, ".elf")) out[len - 4] = '\0';
|
||||
}
|
||||
|
||||
// ==== Layout ====
|
||||
|
||||
gui::mtk::Theme prompt_theme(const DesktopState* ds) {
|
||||
return gui::mtk::make_theme(ds->settings.accent_color);
|
||||
}
|
||||
|
||||
int text_w() {
|
||||
return PROMPT_W - 2 * BORDER_WIDTH - PAD * 2 - ICON - 14;
|
||||
}
|
||||
|
||||
int line_h() {
|
||||
return system_font_height() + 5;
|
||||
}
|
||||
|
||||
Rect allow_rect(int cw, int ch, const gui::mtk::Theme& theme) {
|
||||
return {cw - PAD - BTN_W * 2 - BTN_GAP, ch - PAD - theme.control_h, BTN_W, theme.control_h};
|
||||
}
|
||||
|
||||
Rect deny_rect(int cw, int ch, const gui::mtk::Theme& theme) {
|
||||
return {cw - PAD - BTN_W, ch - PAD - theme.control_h, BTN_W, theme.control_h};
|
||||
}
|
||||
|
||||
int content_height(const ConsentPrompt* cp, const gui::mtk::Theme& theme) {
|
||||
int h = PAD;
|
||||
h += cp->headline_lines * line_h() + 10;
|
||||
h += cp->path_lines * line_h() + 12;
|
||||
h += line_h() * 2 + 8; // trust advice
|
||||
if (!cp->req.imageProtected) h += line_h() * 2 + 8;
|
||||
h += theme.control_h + PAD;
|
||||
return h;
|
||||
}
|
||||
|
||||
// ==== Answering ====
|
||||
|
||||
ConsentPrompt* prompt_of(Window* win) {
|
||||
return win ? (ConsentPrompt*)win->app_data : nullptr;
|
||||
}
|
||||
|
||||
// `remember` is the user's intent; it only takes effect for an installed
|
||||
// program, since any other binary can be swapped at the same path.
|
||||
void answer(ConsentPrompt* cp, bool allow, bool remember) {
|
||||
if (cp->answered) return;
|
||||
cp->answered = true;
|
||||
if (remember && cp->req.imageProtected &&
|
||||
montauk::consent::record(cp->req.imagePath, cp->req.capability, allow) < 0) {
|
||||
montauk::print("[desktop] consent: could not save the decision to 0:/config/consent.toml\n");
|
||||
}
|
||||
montauk::consent_reply(cp->req.id, allow ? montauk::abi::CONSENT_ALLOW
|
||||
: montauk::abi::CONSENT_DENY);
|
||||
}
|
||||
|
||||
void close_prompt(ConsentPrompt* cp) {
|
||||
DesktopState* ds = cp->ds;
|
||||
for (int i = 0; i < ds->window_count; i++) {
|
||||
if (ds->windows[i].app_data == cp) {
|
||||
desktop_close_window(ds, i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool prompt_is_front(const DesktopState* ds, const Window* win) {
|
||||
return !ds->screen_locked && ds->window_count > 0 &&
|
||||
win == &ds->windows[ds->window_count - 1] && win->focused &&
|
||||
win->state != WIN_MINIMIZED && win->state != WIN_CLOSED;
|
||||
}
|
||||
|
||||
// ==== Window callbacks ====
|
||||
|
||||
void prompt_on_draw(Window* win, Framebuffer& fb) {
|
||||
(void)fb;
|
||||
ConsentPrompt* cp = prompt_of(win);
|
||||
if (!cp) return;
|
||||
gui::mtk::Theme theme = prompt_theme(cp->ds);
|
||||
|
||||
Canvas c(win);
|
||||
c.fill(theme.window_bg);
|
||||
|
||||
if (const SvgIcon* icon = icon_for(cp->req.capability)) c.icon(PAD, PAD, *icon);
|
||||
|
||||
int x = PAD + ICON + 14;
|
||||
int y = PAD;
|
||||
int lh = line_h();
|
||||
for (int i = 0; i < cp->headline_lines; i++, y += lh)
|
||||
c.text(x, y, cp->headline[i], theme.text);
|
||||
y += 10;
|
||||
|
||||
for (int i = 0; i < cp->path_lines; i++, y += lh)
|
||||
c.text(x, y, cp->path[i], theme.text_subtle);
|
||||
y += 12;
|
||||
|
||||
c.text(x, y, "Only allow this if you trust the app and", theme.text_muted);
|
||||
c.text(x, y + lh, "expected it to ask.", theme.text_muted);
|
||||
if (!cp->req.imageProtected) {
|
||||
y += lh * 2 + 8;
|
||||
c.text(x, y, "This app is not installed in 0:/apps, so the", theme.text_muted);
|
||||
c.text(x, y + lh, "answer applies only until it exits.", theme.text_muted);
|
||||
}
|
||||
|
||||
gui::mtk::draw_button(c, allow_rect(c.w, c.h, theme), "Allow",
|
||||
gui::mtk::BUTTON_SECONDARY,
|
||||
gui::mtk::widget_state(false, cp->hover_allow && cp->armed, cp->armed),
|
||||
theme);
|
||||
gui::mtk::draw_button(c, deny_rect(c.w, c.h, theme), "Don't Allow",
|
||||
gui::mtk::BUTTON_PRIMARY,
|
||||
gui::mtk::widget_state(false, cp->hover_deny, true),
|
||||
theme);
|
||||
}
|
||||
|
||||
void prompt_on_poll(Window* win) {
|
||||
ConsentPrompt* cp = prompt_of(win);
|
||||
if (!cp || cp->answered) return;
|
||||
uint64_t now = montauk::get_milliseconds();
|
||||
|
||||
// The kernel has already denied it; an answer now would be refused.
|
||||
if (now - cp->opened_at >= montauk::abi::CONSENT_TIMEOUT_MS) {
|
||||
cp->answered = true;
|
||||
close_prompt(cp);
|
||||
return;
|
||||
}
|
||||
|
||||
bool front = prompt_is_front(cp->ds, win);
|
||||
if (!front) {
|
||||
cp->armed_since = 0;
|
||||
} else if (cp->armed_since == 0) {
|
||||
cp->armed_since = now;
|
||||
}
|
||||
bool armed = front && now - cp->armed_since >= ARM_DELAY_MS;
|
||||
if (armed != cp->armed) {
|
||||
cp->armed = armed;
|
||||
win->dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
void prompt_on_mouse(Window* win, MouseEvent& ev) {
|
||||
ConsentPrompt* cp = prompt_of(win);
|
||||
if (!cp || cp->answered) return;
|
||||
|
||||
gui::mtk::Theme theme = prompt_theme(cp->ds);
|
||||
gui::mtk::DesktopHost host(win);
|
||||
int mx = 0;
|
||||
int my = 0;
|
||||
if (!host.map_mouse(ev, &mx, &my)) return;
|
||||
cp->hover_allow = allow_rect(win->content_w, win->content_h, theme).contains(mx, my);
|
||||
cp->hover_deny = deny_rect(win->content_w, win->content_h, theme).contains(mx, my);
|
||||
|
||||
if (!ev.left_pressed()) return;
|
||||
if (cp->hover_deny) {
|
||||
answer(cp, false, true);
|
||||
close_prompt(cp);
|
||||
} else if (cp->hover_allow && cp->armed && prompt_is_front(cp->ds, win)) {
|
||||
answer(cp, true, true);
|
||||
close_prompt(cp);
|
||||
}
|
||||
}
|
||||
|
||||
void prompt_on_key(Window* win, const montauk::abi::KeyEvent& key) {
|
||||
ConsentPrompt* cp = prompt_of(win);
|
||||
if (!cp || cp->answered || !key.pressed) return;
|
||||
|
||||
if (key.scancode == 0x01) { // Escape: dismiss, ask again later
|
||||
answer(cp, false, false);
|
||||
close_prompt(cp);
|
||||
} else if ((key.ascii == '\n' || key.ascii == '\r') && cp->armed) {
|
||||
// Return picks the default, Don't Allow. Armed so a key held while
|
||||
// the prompt appeared does not answer it.
|
||||
answer(cp, false, true);
|
||||
close_prompt(cp);
|
||||
}
|
||||
}
|
||||
|
||||
void prompt_on_close(Window* win) {
|
||||
ConsentPrompt* cp = prompt_of(win);
|
||||
if (!cp) return;
|
||||
// Closed without an answer (title-bar close, session ending): deny this
|
||||
// time only.
|
||||
answer(cp, false, false);
|
||||
if (g_open == cp) g_open = nullptr;
|
||||
montauk::mfree(cp);
|
||||
win->app_data = nullptr;
|
||||
}
|
||||
|
||||
// ==== Opening ====
|
||||
|
||||
// Centre over the requesting app's window when it has one, so the prompt is
|
||||
// visibly about that app; otherwise centre on the screen.
|
||||
void place_prompt(DesktopState* ds, int pid, int w, int h, int* out_x, int* out_y) {
|
||||
int cx = ds->screen_w / 2;
|
||||
int cy = ds->screen_h / 2;
|
||||
|
||||
montauk::abi::WinInfo infos[MAX_WINDOWS];
|
||||
int count = montauk::win_enumerate(infos, MAX_WINDOWS);
|
||||
for (int e = 0; e < count; e++) {
|
||||
if (infos[e].ownerPid != pid) continue;
|
||||
for (int i = 0; i < ds->window_count; i++) {
|
||||
const Window& win = ds->windows[i];
|
||||
if (!win.external || win.ext_win_id != infos[e].id) continue;
|
||||
if (win.state == WIN_MINIMIZED || win.state == WIN_CLOSED) continue;
|
||||
cx = win.frame.x + win.frame.w / 2;
|
||||
cy = win.frame.y + win.frame.h / 2;
|
||||
e = count;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
int x = cx - w / 2;
|
||||
int y = cy - h / 2;
|
||||
if (x + w > ds->screen_w) x = ds->screen_w - w;
|
||||
if (y + h > ds->screen_h) y = ds->screen_h - h;
|
||||
if (x < 0) x = 0;
|
||||
if (y < PANEL_HEIGHT) y = PANEL_HEIGHT;
|
||||
*out_x = x;
|
||||
*out_y = y;
|
||||
}
|
||||
|
||||
bool open_prompt(DesktopState* ds, const montauk::abi::ConsentRequest& req) {
|
||||
ConsentPrompt* cp = (ConsentPrompt*)montauk::malloc(sizeof(ConsentPrompt));
|
||||
if (!cp) return false;
|
||||
montauk::memset(cp, 0, sizeof(*cp));
|
||||
cp->ds = ds;
|
||||
cp->req = req;
|
||||
cp->opened_at = montauk::get_milliseconds();
|
||||
|
||||
char name[48];
|
||||
app_name_for(req.imagePath, name, sizeof(name));
|
||||
make_printable(name);
|
||||
char shown_path[sizeof(req.imagePath)];
|
||||
montauk::strncpy(shown_path, req.imagePath, sizeof(shown_path));
|
||||
make_printable(shown_path);
|
||||
char headline[320];
|
||||
snprintf(headline, sizeof(headline), "\"%s\" wants to %s.", name,
|
||||
montauk::consent::action_for(req.capability));
|
||||
cp->headline_lines = wrap_text(headline, text_w(), cp->headline, MAX_LINES);
|
||||
// The path is where the user can tell a system app from a download.
|
||||
cp->path_lines = wrap_text(shown_path, text_w(), cp->path, 2);
|
||||
|
||||
gui::mtk::Theme theme = prompt_theme(ds);
|
||||
int w = PROMPT_W;
|
||||
int h = content_height(cp, theme) + TITLEBAR_HEIGHT + BORDER_WIDTH;
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
place_prompt(ds, req.pid, w, h, &x, &y);
|
||||
|
||||
int idx = desktop_create_window(ds, "Permission Request", x, y, w, h);
|
||||
if (idx < 0) {
|
||||
montauk::mfree(cp);
|
||||
return false;
|
||||
}
|
||||
|
||||
Window* win = &ds->windows[idx];
|
||||
win->app_data = cp;
|
||||
win->on_draw = prompt_on_draw;
|
||||
win->on_mouse = prompt_on_mouse;
|
||||
win->on_key = prompt_on_key;
|
||||
win->on_poll = prompt_on_poll;
|
||||
win->on_close = prompt_on_close;
|
||||
g_open = cp;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ==== Agent ====
|
||||
|
||||
void desktop_consent_init(DesktopState* ds) {
|
||||
// Every session registers; a standard session simply never receives a
|
||||
// request, because it has none of the eligible capabilities to delegate.
|
||||
g_registered = montauk::consent_register() == 0;
|
||||
if (!g_registered) {
|
||||
montauk::print("[desktop] consent: could not register as the consent agent\n");
|
||||
return;
|
||||
}
|
||||
g_icon_fill = gui::mtk::make_theme(ds->settings.accent_color).text;
|
||||
}
|
||||
|
||||
bool desktop_consent_poll(DesktopState* ds) {
|
||||
if (!g_registered || g_open != nullptr) return false;
|
||||
|
||||
// Drain whatever the stored decisions settle; stop at the first request
|
||||
// that needs the user.
|
||||
for (;;) {
|
||||
montauk::abi::ConsentRequest req;
|
||||
if (montauk::consent_next(&req) != 1) return false;
|
||||
|
||||
int stored = req.imageProtected
|
||||
? montauk::consent::lookup(req.imagePath, req.capability) : -1;
|
||||
if (stored >= 0) {
|
||||
montauk::consent_reply(req.id, stored ? montauk::abi::CONSENT_ALLOW
|
||||
: montauk::abi::CONSENT_DENY);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!open_prompt(ds, req)) {
|
||||
// Could not show it, so the user cannot have agreed.
|
||||
montauk::consent_reply(req.id, montauk::abi::CONSENT_DENY);
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -181,6 +181,10 @@ inline gui::Rect desktop_content_rect(const gui::Window* win) {
|
||||
gui::ResizeEdge hit_test_resize_edge(const gui::Rect& f, int mx, int my);
|
||||
gui::CursorStyle cursor_for_edge(gui::ResizeEdge edge);
|
||||
|
||||
// consent.cpp
|
||||
void desktop_consent_init(gui::DesktopState* ds);
|
||||
bool desktop_consent_poll(gui::DesktopState* ds);
|
||||
|
||||
// panel.cpp
|
||||
void desktop_draw_app_menu(gui::DesktopState* ds);
|
||||
void desktop_draw_net_popup(gui::DesktopState* ds);
|
||||
|
||||
@@ -393,6 +393,7 @@ void gui::desktop_init(DesktopState* ds) {
|
||||
desktop_refresh_netifs(ds);
|
||||
desktop_wifi_init(ds);
|
||||
desktop_battery_init(ds);
|
||||
desktop_consent_init(ds);
|
||||
|
||||
ds->vol_popup_open = false;
|
||||
ds->vol_icon_rect = {0, 0, 0, 0};
|
||||
@@ -796,6 +797,9 @@ void gui::desktop_run(DesktopState* ds) {
|
||||
// Poll external windows (discover new, remove dead, update dirty)
|
||||
sceneChanged |= desktop_poll_external_windows(ds);
|
||||
|
||||
// Answer or prompt for pending first-use capability requests.
|
||||
if (!ds->screen_locked) sceneChanged |= desktop_consent_poll(ds);
|
||||
|
||||
if (!ds->screen_locked) {
|
||||
// Poll windows that have a poll callback
|
||||
for (int i = 0; i < ds->window_count; i++) {
|
||||
|
||||
@@ -59,11 +59,10 @@ void launch_session(LoginState* ls) {
|
||||
// session is delegable for the same reason an admin one is -- otherwise
|
||||
// its own terminal could not hand the shell the suspend it already has.
|
||||
montauk::abi::SpawnCapabilities caps{};
|
||||
caps.permitted = montauk::user::is_admin(ls->username)
|
||||
caps.effective = montauk::user::is_admin(ls->username)
|
||||
? montauk::abi::CAP_ADMIN_SESSION
|
||||
: montauk::abi::CAP_STANDARD_SESSION;
|
||||
caps.effective = caps.permitted;
|
||||
caps.delegable = caps.permitted;
|
||||
caps.delegable = caps.effective;
|
||||
|
||||
int pid;
|
||||
if (ls->session_mode == SESSION_CONSOLE) {
|
||||
@@ -74,7 +73,6 @@ void launch_session(LoginState* ls) {
|
||||
} else {
|
||||
// The desktop is also the compositor. That authority is not the
|
||||
// session's to pass on, so it is added here and not delegable.
|
||||
caps.permitted |= montauk::abi::CAP_COMPOSITOR;
|
||||
caps.effective |= montauk::abi::CAP_COMPOSITOR;
|
||||
pid = montauk::spawn_with_caps("0:/os/desktop.elf", ls->username,
|
||||
ls->username, caps);
|
||||
|
||||
@@ -27,14 +27,13 @@ static void maybe_run_setup_session() {
|
||||
|
||||
montauk::abi::SpawnCapabilities caps{};
|
||||
bool admin = montauk::streq(role, "admin");
|
||||
caps.permitted = caps.effective = admin
|
||||
caps.effective = admin
|
||||
? montauk::abi::CAP_ADMIN_SESSION
|
||||
: montauk::abi::CAP_STANDARD_SESSION;
|
||||
// Matches launch_session(): a session delegates from what it holds, and
|
||||
// the grant table decides which program receives which part of it.
|
||||
caps.delegable = caps.permitted;
|
||||
caps.delegable = caps.effective;
|
||||
// Compositor authority for the desktop itself, never delegable onwards.
|
||||
caps.permitted |= montauk::abi::CAP_COMPOSITOR;
|
||||
caps.effective |= montauk::abi::CAP_COMPOSITOR;
|
||||
int pid = montauk::spawn_with_caps("0:/os/desktop.elf", user, user, caps);
|
||||
if (pid >= 0) {
|
||||
|
||||
Reference in new issue
Block a user