From 1f85ef6aa238281fe82ed8ac4793d5e921548563 Mon Sep 17 00:00:00 2001 From: SikongJueluo Date: Sun, 12 Oct 2025 16:50:48 +0800 Subject: [PATCH] update and add tui for accesscontrol --- devenv.lock | 4 +- devenv.nix | 10 +- devenv.yaml | 4 +- src/accesscontrol/logviewer.ts | 110 ++++++ src/accesscontrol/main.ts | 36 +- src/accesscontrol/tui.ts | 615 +++++++++++++++++++++++++++++++++ 6 files changed, 768 insertions(+), 11 deletions(-) create mode 100644 src/accesscontrol/logviewer.ts diff --git a/devenv.lock b/devenv.lock index 55257c5..74085d4 100644 --- a/devenv.lock +++ b/devenv.lock @@ -3,10 +3,10 @@ "devenv": { "locked": { "dir": "src/modules", - "lastModified": 1759939975, + "lastModified": 1760162706, "owner": "cachix", "repo": "devenv", - "rev": "6eda3b7af3010d289e6e8e047435956fc80c1395", + "rev": "0d5ad578728fe4bce66eb4398b8b1e66deceb4e4", "type": "github" }, "original": { diff --git a/devenv.nix b/devenv.nix index ed78bce..2b9fd20 100644 --- a/devenv.nix +++ b/devenv.nix @@ -1,11 +1,13 @@ -{ pkgs, lib, config, inputs, ... }: - { + pkgs, + lib, + config, + inputs, + ... +}: { packages = with pkgs; [ pnpm craftos-pc - qwen-code - gemini-cli ]; # https://devenv.sh/languages/ diff --git a/devenv.yaml b/devenv.yaml index 116a2ad..2fadd3c 100644 --- a/devenv.yaml +++ b/devenv.yaml @@ -2,10 +2,8 @@ inputs: nixpkgs: url: github:cachix/devenv-nixpkgs/rolling - # If you're using non-OSS software, you can set allowUnfree to true. -# allowUnfree: true - +allowUnfree: true # If you're willing to use a package that's vulnerable # permittedInsecurePackages: # - "openssl-1.1.1w" diff --git a/src/accesscontrol/logviewer.ts b/src/accesscontrol/logviewer.ts new file mode 100644 index 0000000..520c087 --- /dev/null +++ b/src/accesscontrol/logviewer.ts @@ -0,0 +1,110 @@ +/** + * Access Control Log Viewer + * Simple log viewer that allows launching the TUI with 'c' key + */ + +import { launchAccessControlTUI } from "./tui"; + +const args = [...$vararg]; + +function displayLog(filepath: string, lines = 20) { + const [file] = io.open(filepath, "r"); + if (!file) { + print(`Failed to open log file: ${filepath}`); + return; + } + + const content = file.read("*a"); + file.close(); + + if (content === null || content === undefined || content === "") { + print("Log file is empty"); + return; + } + + const logLines = content.split("\n"); + const startIndex = Math.max(0, logLines.length - lines); + const displayLines = logLines.slice(startIndex); + + term.clear(); + term.setCursorPos(1, 1); + + print("=== Access Control Log Viewer ==="); + print("Press 'c' to open configuration TUI, 'q' to quit, 'r' to refresh"); + print("=========================================="); + print(""); + + for (const line of displayLines) { + if (line.trim() !== "") { + print(line); + } + } + + print(""); + print("=========================================="); + print(`Showing last ${displayLines.length} lines of ${filepath}`); +} + +function main(args: string[]) { + const logFilepath = args[0] || `${shell.dir()}/accesscontrol.log`; + const lines = args[1] ? parseInt(args[1]) : 20; + + if (isNaN(lines) || lines <= 0) { + print("Usage: logviewer [logfile] [lines]"); + print(" logfile - Path to log file (default: accesscontrol.log)"); + print(" lines - Number of lines to display (default: 20)"); + return; + } + + let running = true; + + // Initial display + displayLog(logFilepath, lines); + + while (running) { + const [eventType, key] = os.pullEvent(); + + if (eventType === "key") { + if (key === keys.c) { + // Launch TUI + print("Launching Access Control TUI..."); + try { + launchAccessControlTUI(); + // Refresh display after TUI closes + displayLog(logFilepath, lines); + } catch (error) { + if (error === "TUI_CLOSE" || error === "Terminated") { + displayLog(logFilepath, lines); + } else { + print(`TUI error: ${String(error)}`); + os.sleep(2); + displayLog(logFilepath, lines); + } + } + } else if (key === keys.q) { + // Quit + running = false; + } else if (key === keys.r) { + // Refresh + displayLog(logFilepath, lines); + } + } else if (eventType === "terminate") { + running = false; + } + } + + term.clear(); + term.setCursorPos(1, 1); + print("Log viewer closed."); +} + +try { + main(args); +} catch (error) { + if (error === "Terminated") { + print("Log viewer terminated by user."); + } else { + print("Error in log viewer:"); + printError(error); + } +} diff --git a/src/accesscontrol/main.ts b/src/accesscontrol/main.ts index a3e757e..8e8dbe5 100644 --- a/src/accesscontrol/main.ts +++ b/src/accesscontrol/main.ts @@ -1,13 +1,14 @@ import { CCLog, DAY } from "@/lib/ccLog"; import { ToastConfig, UserGroupConfig, loadConfig, setLog } from "./config"; import { createAccessControlCLI } from "./cli"; +import { launchAccessControlTUI } from "./tui"; import * as peripheralManager from "../lib/PeripheralManager"; const DEBUG = false; const args = [...$vararg]; // Init Log -const log = new CCLog("accesscontrol.log", DAY); +const log = new CCLog("accesscontrol.log", true, DAY); setLog(log); // Load Config @@ -174,6 +175,21 @@ function mainLoop() { } } +function keyboardLoop() { + while (true) { + const [eventType, key] = os.pullEvent("key"); + if (eventType === "key" && key === keys.c) { + log.info("Launching Access Control TUI..."); + try { + launchAccessControlTUI(); + log.info("TUI closed, resuming normal operation"); + } catch (error) { + log.error(`TUI error: ${textutils.serialise(error as object)}`); + } + } + } +} + function main(args: string[]) { log.info("Starting access control system, get args: " + args.join(", ")); if (args.length == 1) { @@ -187,6 +203,9 @@ function main(args: string[]) { groupNames, ); + print( + "Access Control System started. Press 'c' to open configuration TUI.", + ); parallel.waitForAll( () => { mainLoop(); @@ -197,12 +216,25 @@ function main(args: string[]) { () => { watchLoop(); }, + () => { + keyboardLoop(); + }, ); return; + } else if (args[0] == "config") { + log.info("Launching Access Control TUI..."); + try { + launchAccessControlTUI(); + } catch (error) { + log.error(`TUI error: ${textutils.serialise(error as object)}`); + } + return; } } - print(`Usage: accesscontrol start`); + print(`Usage: accesscontrol start | config`); + print(" start - Start the access control system with monitoring"); + print(" config - Open configuration TUI"); } try { diff --git a/src/accesscontrol/tui.ts b/src/accesscontrol/tui.ts index e69de29..04f8315 100644 --- a/src/accesscontrol/tui.ts +++ b/src/accesscontrol/tui.ts @@ -0,0 +1,615 @@ +/** + * Access Control TUI Implementation + * A text-based user interface for configuring access control settings + */ + +import { + createSignal, + createStore, + div, + label, + button, + input, + h1, + render, + Show, + For, +} from "../lib/ccTUI"; +import { + AccessConfig, + UserGroupConfig, + loadConfig, + saveConfig, +} from "./config"; + +// Tab indices +const TABS = { + BASIC: 0, + GROUPS: 1, + WELCOME_TOAST: 2, + WARN_TOAST: 3, + NOTICE_TOAST: 4, +} as const; + +type TabIndex = (typeof TABS)[keyof typeof TABS]; + +// Error dialog state +interface ErrorState { + show: boolean; + message: string; +} + +/** + * Main TUI Application Component + */ +const AccessControlTUI = () => { + // Configuration state + const [config, setConfig] = createStore({} as AccessConfig); + + // UI state + const [currentTab, setCurrentTab] = createSignal(TABS.BASIC); + const [selectedGroupIndex, setSelectedGroupIndex] = createSignal(0); + const [errorState, setErrorState] = createStore({ + show: false, + message: "", + }); + + // New user input for group management + const [newUserName, setNewUserName] = createSignal(""); + + // Load configuration on initialization + const configFilepath = `${shell.dir()}/access.config.json`; + const loadedConfig = loadConfig(configFilepath); + setConfig(() => loadedConfig); + + // Tab navigation functions + const tabNames = [ + "Basic", + "Groups", + "Welcome Toast", + "Warn Toast", + "Notice Toast", + ]; + + const showError = (message: string) => { + setErrorState("show", true); + setErrorState("message", message); + }; + + const hideError = () => { + setErrorState("show", false); + setErrorState("message", ""); + }; + + // Validation functions + const validateNumber = (value: string): number | null => { + const num = parseInt(value); + return isNaN(num) ? null : num; + }; + + const validateTextComponent = (value: string): boolean => { + try { + const parsed = textutils.unserialiseJSON(value); + return parsed !== undefined && typeof parsed === "object"; + } catch { + return false; + } + }; + + // Save configuration with validation + const handleSave = () => { + try { + const currentConfig = config(); + + // Validate numbers + if ( + validateNumber(currentConfig.detectInterval?.toString() ?? "") === null + ) { + showError("Invalid Detect Interval: must be a number"); + return; + } + if ( + validateNumber(currentConfig.watchInterval?.toString() ?? "") === null + ) { + showError("Invalid Watch Interval: must be a number"); + return; + } + if ( + validateNumber(currentConfig.noticeTimes?.toString() ?? "") === null + ) { + showError("Invalid Notice Times: must be a number"); + return; + } + if ( + validateNumber(currentConfig.detectRange?.toString() ?? "") === null + ) { + showError("Invalid Detect Range: must be a number"); + return; + } + + // Validate text components for toast configs + const toastConfigs = [ + { + name: "Welcome Toast Title", + value: currentConfig.welcomeToastConfig?.title, + }, + { + name: "Welcome Toast Message", + value: currentConfig.welcomeToastConfig?.msg, + }, + { + name: "Warn Toast Title", + value: currentConfig.warnToastConfig?.title, + }, + { + name: "Warn Toast Message", + value: currentConfig.warnToastConfig?.msg, + }, + { + name: "Notice Toast Title", + value: currentConfig.noticeToastConfig?.title, + }, + { + name: "Notice Toast Message", + value: currentConfig.noticeToastConfig?.msg, + }, + ]; + + for (const toastConfig of toastConfigs) { + if (toastConfig.value != undefined) { + const serialized = textutils.serialiseJSON(toastConfig.value); + if (!validateTextComponent(serialized)) { + showError( + `Invalid ${toastConfig.name}: must be valid MinecraftTextComponent JSON`, + ); + return; + } + } + } + + // Save configuration + saveConfig(currentConfig, configFilepath); + showError("Configuration saved successfully!"); + } catch (error) { + showError(`Failed to save configuration: ${String(error)}`); + } + }; + + // Add user to selected group + const addUser = () => { + const userName = newUserName().trim(); + if (userName === "") return; + + const groupIndex = selectedGroupIndex(); + if (groupIndex === 0) { + // Admin group + const currentAdmin = config().adminGroupConfig; + setConfig("adminGroupConfig", { + ...currentAdmin, + groupUsers: [...currentAdmin.groupUsers, userName], + }); + } else { + // Regular group + const actualIndex = groupIndex - 1; + const currentGroups = config().usersGroups; + const currentGroup = currentGroups[actualIndex]; + const newGroups = [...currentGroups]; + newGroups[actualIndex] = { + ...currentGroup, + groupUsers: [...(currentGroup?.groupUsers ?? []), userName], + }; + setConfig("usersGroups", newGroups); + } + setNewUserName(""); + }; + + // Remove user from selected group + const removeUser = (userName: string) => { + const groupIndex = selectedGroupIndex(); + if (groupIndex === 0) { + // Admin group + const currentAdmin = config().adminGroupConfig; + setConfig("adminGroupConfig", { + ...currentAdmin, + groupUsers: currentAdmin.groupUsers.filter((user) => user !== userName), + }); + } else { + // Regular group + const actualIndex = groupIndex - 1; + const currentGroups = config().usersGroups; + const currentGroup = currentGroups[actualIndex]; + const newGroups = [...currentGroups]; + newGroups[actualIndex] = { + ...currentGroup, + groupUsers: (currentGroup?.groupUsers ?? []).filter( + (user) => user !== userName, + ), + }; + setConfig("usersGroups", newGroups); + } + }; + + // Get all groups for selection + const getAllGroups = (): UserGroupConfig[] => { + const currentConfig = config(); + return [currentConfig.adminGroupConfig, ...currentConfig.usersGroups]; + }; + + // Get currently selected group + const getSelectedGroup = (): UserGroupConfig => { + const groups = getAllGroups(); + return groups[selectedGroupIndex()] ?? config().adminGroupConfig; + }; + + /** + * Basic Configuration Tab + */ + const BasicTab = () => { + return div( + { class: "flex flex-col" }, + label({}, "Detect Interval (ms):"), + input({ + type: "text", + value: () => config().detectInterval?.toString() ?? "", + onInput: (value) => { + const num = validateNumber(value); + if (num !== null) setConfig("detectInterval", num); + }, + }), + + label({}, "Watch Interval (ms):"), + input({ + type: "text", + value: () => config().watchInterval?.toString() ?? "", + onInput: (value) => { + const num = validateNumber(value); + if (num !== null) setConfig("watchInterval", num); + }, + }), + + label({}, "Notice Times:"), + input({ + type: "text", + value: () => config().noticeTimes?.toString() ?? "", + onInput: (value) => { + const num = validateNumber(value); + if (num !== null) setConfig("noticeTimes", num); + }, + }), + + label({}, "Detect Range:"), + input({ + type: "text", + value: () => config().detectRange?.toString() ?? "", + onInput: (value) => { + const num = validateNumber(value); + if (num !== null) setConfig("detectRange", num); + }, + }), + + label({}, "Is Warn:"), + input({ + type: "checkbox", + checked: () => config().isWarn ?? false, + onChange: (checked) => setConfig("isWarn", checked), + }), + ); + }; + + /** + * Groups Configuration Tab + */ + const GroupsTab = () => { + const groups = getAllGroups(); + const selectedGroup = getSelectedGroup(); + + return div( + { class: "flex flex-row" }, + // Left side - Groups list + div( + { class: "flex flex-col" }, + label({}, "Groups:"), + For({ each: () => groups }, (group, index) => + button( + { + class: + selectedGroupIndex() === index() ? "bg-blue text-white" : "", + onClick: () => setSelectedGroupIndex(index()), + }, + group.groupName, + ), + ), + ), + + // Right side - Group details + div( + { class: "flex flex-col ml-2" }, + label({}, () => `Group: ${selectedGroup.groupName}`), + + label({}, "Is Allowed:"), + input({ + type: "checkbox", + checked: () => selectedGroup.isAllowed, + onChange: (checked) => { + const groupIndex = selectedGroupIndex(); + if (groupIndex === 0) { + const currentAdmin = config().adminGroupConfig; + setConfig("adminGroupConfig", { + ...currentAdmin, + isAllowed: checked, + }); + } else { + const actualIndex = groupIndex - 1; + const currentGroups = config().usersGroups; + const currentGroup = currentGroups[actualIndex]; + const newGroups = [...currentGroups]; + newGroups[actualIndex] = { + ...currentGroup, + isAllowed: checked, + }; + setConfig("usersGroups", newGroups); + } + }, + }), + + label({}, "Is Notice:"), + input({ + type: "checkbox", + checked: () => selectedGroup.isNotice, + onChange: (checked) => { + const groupIndex = selectedGroupIndex(); + if (groupIndex === 0) { + const currentAdmin = config().adminGroupConfig; + setConfig("adminGroupConfig", { + ...currentAdmin, + isNotice: checked, + }); + } else { + const actualIndex = groupIndex - 1; + const currentGroups = config().usersGroups; + const currentGroup = currentGroups[actualIndex]; + const newGroups = [...currentGroups]; + newGroups[actualIndex] = { + ...currentGroup, + isNotice: checked, + }; + setConfig("usersGroups", newGroups); + } + }, + }), + + label({}, "Group Users:"), + // User management + div( + { class: "flex flex-row" }, + input({ + type: "text", + value: newUserName, + onInput: setNewUserName, + placeholder: "Enter username", + }), + button({ onClick: addUser }, "Add"), + ), + + // Users list + For({ each: () => selectedGroup.groupUsers ?? [] }, (user) => + div( + { class: "flex flex-row items-center" }, + label({}, user), + button( + { + class: "ml-1 bg-red text-white", + onClick: () => removeUser(user), + }, + "Remove", + ), + ), + ), + ), + ); + }; + + /** + * Toast Configuration Tab Factory + */ + const createToastTab = ( + toastType: "welcomeToastConfig" | "warnToastConfig" | "noticeToastConfig", + ) => { + return () => { + const toastConfig = config()[toastType]; + + return div( + { class: "flex flex-col" }, + label({}, "Title (JSON):"), + input({ + type: "text", + value: () => textutils.serialiseJSON(toastConfig?.title) ?? "", + onInput: (value) => { + try { + const parsed = textutils.unserialiseJSON(value); + if (parsed != undefined && typeof parsed === "object") { + const currentConfig = config(); + const currentToast = currentConfig[toastType]; + setConfig(toastType, { + ...currentToast, + title: parsed as MinecraftTextComponent, + }); + } + } catch { + // Invalid JSON, ignore + } + }, + }), + + label({}, "Message (JSON):"), + input({ + type: "text", + value: () => textutils.serialiseJSON(toastConfig?.msg) ?? "", + onInput: (value) => { + try { + const parsed = textutils.unserialiseJSON(value); + if (parsed != undefined && typeof parsed === "object") { + const currentConfig = config(); + const currentToast = currentConfig[toastType]; + setConfig(toastType, { + ...currentToast, + msg: parsed as MinecraftTextComponent, + }); + } + } catch { + // Invalid JSON, ignore + } + }, + }), + + label({}, "Prefix:"), + input({ + type: "text", + value: () => toastConfig?.prefix ?? "", + onInput: (value) => { + const currentConfig = config(); + const currentToast = currentConfig[toastType]; + setConfig(toastType, { ...currentToast, prefix: value }); + }, + }), + + label({}, "Brackets:"), + input({ + type: "text", + value: () => toastConfig?.brackets ?? "", + onInput: (value) => { + const currentConfig = config(); + const currentToast = currentConfig[toastType]; + setConfig(toastType, { ...currentToast, brackets: value }); + }, + }), + + label({}, "Bracket Color:"), + input({ + type: "text", + value: () => toastConfig?.bracketColor ?? "", + onInput: (value) => { + const currentConfig = config(); + const currentToast = currentConfig[toastType]; + setConfig(toastType, { ...currentToast, bracketColor: value }); + }, + }), + ); + }; + }; + + // Create toast tab components + const WelcomeToastTab = createToastTab("welcomeToastConfig"); + const WarnToastTab = createToastTab("warnToastConfig"); + const NoticeToastTab = createToastTab("noticeToastConfig"); + + /** + * Error Dialog + */ + const ErrorDialog = () => { + return Show( + { when: () => errorState().show }, + div( + { + class: + "fixed top-1/4 left-1/4 right-1/4 bottom-1/4 bg-red text-white border", + }, + div( + { class: "flex flex-col p-2" }, + label({}, () => errorState().message), + button( + { + class: "mt-2 bg-white text-black", + onClick: hideError, + }, + "OK", + ), + ), + ), + ); + }; + + /** + * Tab Content Renderer + */ + const TabContent = () => { + const tab = currentTab(); + if (tab === TABS.BASIC) return BasicTab(); + if (tab === TABS.GROUPS) return GroupsTab(); + if (tab === TABS.WELCOME_TOAST) return WelcomeToastTab(); + if (tab === TABS.WARN_TOAST) return WarnToastTab(); + if (tab === TABS.NOTICE_TOAST) return NoticeToastTab(); + return BasicTab(); // fallback + }; + + /** + * Main UI Layout + */ + return div( + { class: "flex flex-col h-full" }, + // Header + h1("Access Control Configuration"), + + // Tab bar + div( + { class: "flex flex-row" }, + For({ each: () => tabNames }, (tabName, index) => + button( + { + class: currentTab() === index() ? "bg-blue text-white" : "", + onClick: () => setCurrentTab(index() as TabIndex), + }, + tabName, + ), + ), + ), + + // Content area + div({ class: "flex-1 p-2" }, TabContent()), + + // Action buttons + div( + { class: "flex flex-row justify-center p-2" }, + button( + { + class: "bg-green text-white mr-2", + onClick: handleSave, + }, + "Save", + ), + button( + { + class: "bg-gray text-white", + onClick: () => { + // Close TUI - this will be handled by the application framework + error("TUI_CLOSE"); + }, + }, + "Close", + ), + ), + + // Error dialog overlay + ErrorDialog(), + ); +}; + +/** + * Launch the Access Control TUI + */ +export function launchAccessControlTUI(): void { + try { + render(AccessControlTUI); + } catch (e) { + if (e === "TUI_CLOSE" || e === "Terminated") { + // Normal exit + return; + } else { + print("Error in Access Control TUI:"); + printError(e); + } + } +} + +// Export the main component for external use +export { AccessControlTUI };