mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-26 05:01:53 +08:00
Enhance architecture documentation and update project dependencies. Introduce new features for browser recording, page callables, and transform capabilities. Improve build scripts and permissions for better functionality.
This commit is contained in:
+254
-69
@@ -45,7 +45,8 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0;
|
||||
.page-heading > button:not(.ui-button):focus-visible,
|
||||
.editor-actions > button:not(.ui-button):focus-visible,
|
||||
.panel-title > button:not(.ui-button):focus-visible,
|
||||
.data-row:focus-visible, .network-row:focus-visible, .observation-row:focus-visible,
|
||||
.data-row:focus-visible, .network-row:focus-visible, .recording-traces button:focus-visible,
|
||||
.recording-pipeline-step > button:focus-visible,
|
||||
.task-workflow-list button:focus-visible, .context-node-list button:focus-visible,
|
||||
.sidebar nav button:focus-visible {
|
||||
outline: none;
|
||||
@@ -57,7 +58,7 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0;
|
||||
/* 所有单列纵向 grid 容器必须显式 minmax(0,1fr),否则子元素 max-content 会撑破窄屏 */
|
||||
.content-area, .section-view, .settings-form, .list-pane, .editor-pane, .rule-editor,
|
||||
.pairing-workspace, .panel-policy-settings, .grant-editor, .protocol-panel,
|
||||
.observation-section, .network-inspector, .context-primary, .context-inspector,
|
||||
.recording-section, .network-inspector, .context-primary, .context-inspector,
|
||||
.context-inspector > section, .context-diff, .context-inventory, .context-node-browser,
|
||||
.context-mode, .context-json, .context-utility-panel, .tab-picker, .tab-picker-group, .data-list,
|
||||
.task-workflow-list, .cookie-transfer, .network-artifact { grid-template-columns: minmax(0, 1fr); }
|
||||
@@ -72,7 +73,10 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0;
|
||||
.sidebar { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--surface); color: var(--foreground); }
|
||||
.sidebar-brand { height: 64px; padding: 0 14px; display: flex; align-items: center; border-bottom: 1px solid var(--border); }
|
||||
.sidebar-brand .product-brand { width: 100%; color: var(--foreground); }
|
||||
.sidebar nav { padding: 14px 10px; display: grid; gap: 2px; }
|
||||
.sidebar nav { min-height: 0; padding: 10px 10px 16px; overflow-y: auto; display: grid; gap: 10px; scrollbar-width: thin; }
|
||||
.sidebar-group { display: grid; gap: 2px; }
|
||||
.sidebar-group__label { min-height: 24px; padding: 0 10px; display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; }
|
||||
.sidebar-group__label svg { color: var(--primary); }
|
||||
.sidebar nav button { width: 100%; height: 40px; padding: 0 10px; display: grid; grid-template-columns: 20px 1fr 14px; align-items: center; gap: 8px; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--muted-strong); font-size: var(--text-md); font-weight: 500; text-align: left; cursor: pointer; transition: background-color .14s ease, color .14s ease; }
|
||||
.sidebar nav button:hover { background: var(--surface-subtle); color: var(--foreground); }
|
||||
.sidebar nav button.active { background: var(--surface-subtle); color: var(--foreground); box-shadow: inset 3px 0 0 var(--primary); }
|
||||
@@ -142,6 +146,32 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0;
|
||||
.page-heading h1 { margin: 0; font-size: var(--text-2xl); font-weight: 700; line-height: 28px; }
|
||||
.page-heading p { margin: 5px 0 0; color: var(--muted); font-size: var(--text-sm); line-height: 17px; }
|
||||
.section-view h2 { margin: 0; font-size: var(--text-lg); font-weight: 650; }
|
||||
.page-eyebrow { display: block; margin-bottom: 4px; color: var(--primary); font-size: var(--text-xs); font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
|
||||
|
||||
/* User-Agent 常用工具 */
|
||||
.ua-current-site { min-height: 82px; padding: 14px 16px; display: grid; grid-template-columns: minmax(210px, .9fr) minmax(260px, 1fr) auto; align-items: center; gap: 16px; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); background: var(--surface); }
|
||||
.ua-current-site > div:first-child { min-width: 0; }
|
||||
.ua-current-site > div:first-child span, .ua-current-site > div:first-child strong, .ua-current-site > div:first-child small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.ua-current-site > div:first-child span { color: var(--muted); font-size: var(--text-xs); }
|
||||
.ua-current-site > div:first-child strong { margin-top: 3px; font-size: var(--text-lg); }
|
||||
.ua-current-site > div:first-child small { margin-top: 3px; color: var(--muted-strong); font-size: var(--text-sm); }
|
||||
.ua-management { min-height: 420px; display: grid; grid-template-columns: minmax(420px, 1.35fr) minmax(320px, .8fr); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); background: var(--surface); }
|
||||
.ua-assignments { min-width: 0; padding: 16px; border-right: 1px solid var(--border); }
|
||||
.ua-profile-editor { min-width: 0; padding: 16px; display: grid; gap: 12px; align-content: start; }
|
||||
.ua-assignment-list { margin-top: 12px; display: grid; }
|
||||
.ua-assignment-list > div { min-height: 58px; display: grid; grid-template-columns: minmax(160px, .55fr) minmax(220px, 1fr) 34px; align-items: center; gap: 12px; border-top: 1px solid var(--border); }
|
||||
.ua-assignment-list > div:last-child { border-bottom: 1px solid var(--border); }
|
||||
.ua-assignment-list strong, .ua-assignment-list small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.ua-assignment-list strong { font-size: var(--text-sm); }
|
||||
.ua-assignment-list small { margin-top: 3px; color: var(--muted); font-size: var(--text-xs); }
|
||||
.ua-assignment-list code { overflow: hidden; color: var(--muted-strong); font-size: 10px; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.custom-ua-list { display: grid; }
|
||||
.custom-ua-list > div { min-height: 48px; display: grid; grid-template-columns: minmax(0, 1fr) 34px; align-items: center; border-top: 1px solid var(--border); }
|
||||
.custom-ua-list > div:last-child { border-bottom: 1px solid var(--border); }
|
||||
.custom-ua-list > div > button:first-child { min-width: 0; padding: 7px 0; border: 0; background: transparent; color: var(--foreground); text-align: left; cursor: pointer; }
|
||||
.custom-ua-list strong, .custom-ua-list small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.custom-ua-list strong { font-size: var(--text-sm); }
|
||||
.custom-ua-list small { margin-top: 2px; color: var(--muted); font-size: 9px; }
|
||||
.empty-state { min-height: 130px; padding: 20px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; border-radius: var(--radius-md); color: var(--muted); font-size: var(--text-md); text-align: center; }
|
||||
.status-good { color: var(--success); font-weight: 600; }
|
||||
.status-error { color: var(--danger); font-weight: 600; }
|
||||
@@ -150,7 +180,7 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0;
|
||||
|
||||
/* 代码/报文块 —— 浅色主题用浅灰嵌底,暗色主题用深面板 */
|
||||
.network-packet, .invoke-result, .network-artifact pre, .context-json pre,
|
||||
.proxy-tools pre, .observation-values pre, .observation-stack pre {
|
||||
.proxy-tools pre, .recording-values pre, .recording-evidence pre, .recording-recipe-result pre {
|
||||
margin: 0;
|
||||
padding: 12px 13px;
|
||||
border: 1px solid var(--border);
|
||||
@@ -165,7 +195,8 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0;
|
||||
}
|
||||
[data-theme='dark'] .network-packet, [data-theme='dark'] .invoke-result, [data-theme='dark'] .network-artifact pre,
|
||||
[data-theme='dark'] .context-json pre, [data-theme='dark'] .proxy-tools pre,
|
||||
[data-theme='dark'] .observation-values pre, [data-theme='dark'] .observation-stack pre {
|
||||
[data-theme='dark'] .recording-values pre, [data-theme='dark'] .recording-evidence pre,
|
||||
[data-theme='dark'] .recording-recipe-result pre {
|
||||
border-color: #262c33;
|
||||
background: #12161b;
|
||||
color: #d6dde4;
|
||||
@@ -271,19 +302,7 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0;
|
||||
.rule-editor > h2 { margin-bottom: 2px; }
|
||||
.rule-editor > p { margin: -4px 0 0; color: var(--muted); font-size: var(--text-sm); line-height: 1.5; }
|
||||
|
||||
/* 代理规则 */
|
||||
.proxy-routing-bar { padding: 15px 18px; display: flex; flex-wrap: wrap; gap: 14px; align-items: flex-end; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
|
||||
.proxy-routing-bar .ui-field { width: 200px; }
|
||||
.proxy-preview-input { min-width: 0; flex: 1; display: grid; gap: 6px; }
|
||||
.proxy-preview-input > label { color: var(--muted-strong); font-size: var(--text-sm); font-weight: 600; }
|
||||
.proxy-preview-input > div { display: flex; gap: 6px; align-items: center; }
|
||||
.proxy-preview-result { min-width: 180px; padding: 9px 13px; display: grid; gap: 2px; border-radius: var(--radius-md); background: var(--surface-subtle); }
|
||||
.proxy-preview-result.conflict { background: var(--warning-soft); }
|
||||
.proxy-preview-result small { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.proxy-preview-result strong { font-size: var(--text-md); font-weight: 650; }
|
||||
.proxy-preview-result span { color: var(--muted); font-size: var(--text-sm); }
|
||||
.proxy-preview-result i { color: var(--warning); font-size: var(--text-sm); font-style: normal; font-weight: 600; }
|
||||
.rule-table, .proxy-rule-table { border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); overflow: hidden; }
|
||||
.rule-table { border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); overflow: hidden; }
|
||||
.table-head, .table-row { padding: 0 16px; display: grid; gap: 12px; align-items: center; }
|
||||
.table-head { min-height: 38px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.table-row { min-height: 46px; border-bottom: 1px solid var(--border); font-size: var(--text-md); }
|
||||
@@ -291,21 +310,6 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0;
|
||||
.table-row > * { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.table-row code { font-size: var(--text-sm); }
|
||||
.rule-table .table-head, .rule-table .table-row { grid-template-columns: minmax(110px, 1fr) minmax(180px, 2fr) minmax(110px, 1fr) 64px 34px; }
|
||||
.proxy-rule-table .table-head, .proxy-rule-table .table-row { grid-template-columns: 20px minmax(150px, 1.3fr) minmax(130px, 1fr) 100px 54px 62px 34px; }
|
||||
.proxy-rule-table .table-row { cursor: grab; }
|
||||
.proxy-rule-table .table-row > svg { color: var(--muted); }
|
||||
.proxy-rule-name { padding: 0; display: block; overflow: hidden; border: 0; background: transparent; color: var(--foreground); text-align: left; cursor: pointer; }
|
||||
.proxy-rule-name:hover strong { color: var(--primary-text); }
|
||||
.proxy-rule-name strong, .proxy-rule-name small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.proxy-rule-name strong { font-size: var(--text-md); font-weight: 600; }
|
||||
.proxy-rule-name small { margin-top: 2px; color: var(--muted); font-size: var(--text-sm); }
|
||||
.proxy-tools { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
||||
.proxy-tools > section { min-width: 0; padding: 15px 16px; display: grid; gap: 11px; align-content: start; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
|
||||
.proxy-tools > section > div:first-child { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.proxy-tools pre { max-height: 220px; }
|
||||
.proxy-tools textarea { min-height: 160px; font-family: var(--font-mono); font-size: var(--text-sm); }
|
||||
.proxy-stats p { margin: 0; display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: var(--text-md); }
|
||||
.proxy-stats > span { color: var(--muted); font-size: var(--text-sm); }
|
||||
|
||||
/* Cookie Editor */
|
||||
.url-bar { display: flex; align-items: center; gap: 12px; }
|
||||
@@ -325,17 +329,11 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0;
|
||||
.cookie-name-button { padding: 0; overflow: hidden; border: 0; background: transparent; color: var(--foreground); text-align: left; cursor: pointer; }
|
||||
.cookie-name-button strong { display: block; overflow: hidden; font-size: var(--text-md); font-weight: 600; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.cookie-name-button:hover strong { color: var(--primary-text); }
|
||||
.cookie-value-button { min-width: 0; padding: 3px 6px; display: flex; align-items: center; gap: 6px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--muted-strong); cursor: pointer; }
|
||||
.cookie-value-button:hover { background: var(--surface-subtle); }
|
||||
.cookie-value-button code { overflow: hidden; font-size: var(--text-sm); white-space: nowrap; text-overflow: ellipsis; }
|
||||
.cookie-value-button svg { flex: 0 0 auto; color: var(--muted); }
|
||||
.cookie-value { min-width: 0; padding: 3px 6px; overflow: hidden; color: var(--muted-strong); font-size: var(--text-sm); white-space: nowrap; text-overflow: ellipsis; }
|
||||
.cookie-columns > span > small { display: block; overflow: hidden; color: var(--muted); font-size: var(--text-sm); line-height: 15px; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.tag-list { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.tag-list i { padding: 1px 6px; border-radius: 999px; background: var(--surface-subtle); color: var(--muted-strong); font-size: var(--text-xs); font-style: normal; font-weight: 600; }
|
||||
.cookie-editor-pane { position: sticky; top: 76px; }
|
||||
.secret-field { position: relative; }
|
||||
.secret-field .ui-button--icon { position: absolute; right: 6px; top: 6px; width: 28px; height: 28px; }
|
||||
.secret-field.masked textarea { -webkit-text-security: disc; }
|
||||
.cookie-transfer { display: grid; gap: 10px; }
|
||||
.cookie-transfer .segmented { justify-self: start; }
|
||||
.transfer-status { color: var(--muted); font-size: var(--text-sm); }
|
||||
@@ -393,29 +391,191 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0;
|
||||
.network-artifact strong { font-size: var(--text-md); font-weight: 650; }
|
||||
.network-artifact pre { max-height: 260px; }
|
||||
|
||||
/* 页面行为观测 */
|
||||
.observation-section { padding: 16px 18px; display: grid; gap: 13px; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
|
||||
.observation-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 14px; }
|
||||
.observation-heading span { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.observation-heading h2 { margin-top: 3px; }
|
||||
.observation-controls { padding: 0; box-shadow: none; }
|
||||
.observation-kinds { margin-left: auto; color: var(--muted); font-size: var(--text-sm); }
|
||||
.observation-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 400px); gap: 16px; align-items: start; }
|
||||
.observation-timeline { border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; }
|
||||
.observation-table-head { padding: 0 13px; min-height: 34px; display: grid; grid-template-columns: 92px 96px minmax(0, 1fr) 64px 88px; gap: 10px; align-items: center; border-bottom: 1px solid var(--border); color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.observation-row { width: 100%; padding: 8px 13px; display: grid; grid-template-columns: 92px 96px minmax(0, 1fr) 64px 88px; gap: 10px; align-items: center; border: 0; border-bottom: 1px solid var(--border); background: transparent; color: var(--foreground); font-size: var(--text-sm); text-align: left; cursor: pointer; }
|
||||
.observation-row:last-child { border-bottom: 0; }
|
||||
.observation-row:hover { background: var(--surface-subtle); }
|
||||
.observation-row.selected { background: var(--primary-soft); box-shadow: inset 3px 0 0 var(--primary); }
|
||||
.observation-row > strong { overflow: hidden; font-size: var(--text-sm); font-weight: 650; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.observation-row > span, .observation-row > time { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.observation-target strong, .observation-target small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.observation-target strong { font-weight: 600; }
|
||||
.observation-target small { margin-top: 1px; color: var(--muted); }
|
||||
.observation-inspector { position: static; padding: 0; box-shadow: none; }
|
||||
.observation-values, .observation-stack { display: grid; gap: 7px; }
|
||||
.observation-values > strong, .observation-stack > strong { font-size: var(--text-sm); font-weight: 650; }
|
||||
.observation-values pre, .observation-stack pre { max-height: 180px; }
|
||||
/* 浏览器现场录制:Trace -> Pipeline -> 证据与页面函数 */
|
||||
.recording-section { display: grid; gap: 12px; }
|
||||
.recording-heading { min-height: 54px; display: grid; grid-template-columns: minmax(180px, 1fr) auto minmax(230px, 1fr); align-items: end; gap: 16px; }
|
||||
.recording-heading__identity > span { display: block; margin-bottom: 3px; color: var(--primary); font-size: var(--text-xs); font-weight: 700; }
|
||||
.recording-heading__actions { min-width: 230px; display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
|
||||
.recording-heading__actions.is-inactive { visibility: hidden; pointer-events: none; }
|
||||
.recording-mode-switch { height: 34px; padding: 3px; display: grid; grid-template-columns: repeat(3, auto); gap: 2px; border-radius: var(--radius-md); background: var(--surface-subtle); }
|
||||
.recording-mode-switch button { height: 28px; padding: 0 9px; display: inline-flex; align-items: center; justify-content: center; gap: 5px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--muted-strong); font: inherit; font-size: var(--text-xs); font-weight: 600; cursor: pointer; }
|
||||
.recording-mode-switch button:disabled { cursor: not-allowed; opacity: .48; }
|
||||
.recording-mode-switch button.is-selected { background: var(--surface); color: var(--foreground); box-shadow: var(--shadow-sm); }
|
||||
.recording-mode-switch button.is-selected svg { color: var(--primary); }
|
||||
.recording-mode-panel[hidden] { display: none; }
|
||||
.recording-state { min-height: 30px; padding: 0 10px; display: inline-flex; align-items: center; gap: 7px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted-strong); font-size: var(--text-sm); font-weight: 600; white-space: nowrap; }
|
||||
.recording-state i { width: 7px; height: 7px; border-radius: 50%; background: var(--border-strong); }
|
||||
.recording-state.is-active { border-color: color-mix(in srgb, var(--danger) 32%, var(--border)); background: var(--danger-soft); color: var(--danger); }
|
||||
.recording-state.is-active i { background: var(--danger); animation: pulse 1.2s infinite; }
|
||||
.recording-controls { min-height: 52px; padding: 8px 10px 8px 14px; display: flex; align-items: center; gap: 10px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); }
|
||||
.recording-controls > label { min-width: 220px; display: flex; align-items: center; gap: 9px; }
|
||||
.recording-controls > label span { min-width: 0; }
|
||||
.recording-controls > label strong, .recording-controls > label small { display: block; }
|
||||
.recording-controls > label strong { font-size: var(--text-sm); font-weight: 650; }
|
||||
.recording-controls > label small { margin-top: 1px; color: var(--muted); font-size: var(--text-xs); }
|
||||
.recording-summary { min-width: 0; margin-left: auto; overflow: hidden; color: var(--muted); font-size: var(--text-sm); white-space: nowrap; text-overflow: ellipsis; }
|
||||
.recording-navigation { min-height: 58px; padding: 9px 13px; display: grid; grid-template-columns: 22px minmax(0, 1fr); align-items: center; gap: 10px; border: 1px solid color-mix(in srgb, var(--warning) 32%, var(--border)); border-radius: var(--radius-md); background: var(--warning-soft); }
|
||||
.recording-navigation > svg { color: var(--warning); }
|
||||
.recording-navigation.is-restored { border-color: color-mix(in srgb, var(--success) 32%, var(--border)); background: var(--success-soft); }
|
||||
.recording-navigation.is-restored > svg { color: var(--success); }
|
||||
.recording-navigation.is-failed { border-color: color-mix(in srgb, var(--danger) 30%, var(--border)); background: var(--danger-soft); }
|
||||
.recording-navigation.is-failed > svg { color: var(--danger); }
|
||||
.recording-navigation > div { min-width: 0; }
|
||||
.recording-navigation strong,
|
||||
.recording-navigation span,
|
||||
.recording-navigation code { display: block; }
|
||||
.recording-navigation strong { font-size: var(--text-sm); }
|
||||
.recording-navigation span { margin-top: 2px; color: var(--muted-strong); font-size: var(--text-xs); line-height: 1.45; }
|
||||
.recording-navigation code { margin-top: 3px; overflow: hidden; color: var(--muted-strong); font-size: 10px; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.recording-error { min-height: 46px; padding: 8px 12px; display: flex; align-items: center; gap: 8px; border-left: 3px solid var(--danger); background: var(--danger-soft); color: var(--danger); font-size: var(--text-sm); }
|
||||
.recording-error .ui-button { margin-left: auto; }
|
||||
.recording-empty { min-height: 230px; padding: 30px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); text-align: center; }
|
||||
.recording-empty svg { color: var(--border-strong); }
|
||||
.recording-empty strong { font-size: var(--text-lg); }
|
||||
.recording-empty span { max-width: 520px; color: var(--muted); font-size: var(--text-sm); line-height: 1.55; }
|
||||
.recording-workbench { min-height: 560px; display: grid; grid-template-columns: minmax(210px, 240px) minmax(340px, 1fr) minmax(310px, 380px); overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); }
|
||||
.recording-traces, .recording-pipeline, .recording-inspector { min-width: 0; min-height: 0; }
|
||||
.recording-traces { border-right: 1px solid var(--border); background: var(--surface-subtle); }
|
||||
.recording-traces > header, .recording-pipeline > header { min-height: 45px; padding: 0 13px; display: flex; align-items: center; justify-content: space-between; gap: 8px; border-bottom: 1px solid var(--border); }
|
||||
.recording-traces > header strong, .recording-pipeline > header strong { font-size: var(--text-sm); font-weight: 700; }
|
||||
.recording-traces > header > div strong, .recording-traces > header > div small { display: block; }
|
||||
.recording-traces > header > div small { margin-top: 1px; color: var(--muted); font-size: 10px; font-weight: 500; }
|
||||
.recording-traces > header > span { min-width: 22px; height: 20px; padding: 0 6px; display: inline-grid; place-items: center; border-radius: 999px; background: var(--surface); color: var(--muted); font-size: var(--text-xs); }
|
||||
.recording-traces > div { max-height: 700px; overflow: auto; }
|
||||
.recording-traces button { position: relative; width: 100%; min-height: 72px; padding: 10px 11px; display: grid; grid-template-columns: 31px minmax(0, 1fr); gap: 0 9px; align-items: start; border: 0; border-bottom: 1px solid var(--border); background: transparent; color: var(--foreground); text-align: left; cursor: pointer; }
|
||||
.recording-traces button:hover { background: var(--surface); }
|
||||
.recording-traces button.is-selected { background: var(--surface); box-shadow: inset 3px 0 0 var(--primary); }
|
||||
.recording-trace-index { width: 31px; height: 25px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); color: var(--muted-strong); font: 700 10px/1 var(--font-mono); letter-spacing: .03em; }
|
||||
.recording-traces button.is-selected .recording-trace-index { border-color: color-mix(in srgb, var(--primary) 55%, var(--border)); background: var(--primary-soft); color: var(--primary-text); }
|
||||
.recording-traces button > span:nth-child(2) { min-width: 0; }
|
||||
.recording-traces button strong, .recording-traces button small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.recording-traces button strong { font-size: var(--text-sm); font-weight: 650; }
|
||||
.recording-traces button small { margin-top: 3px; color: var(--muted); font-size: var(--text-xs); }
|
||||
.recording-traces button time { grid-column: 2; margin-top: 5px; display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: 10px; }
|
||||
.recording-traces button time i { padding-left: 7px; border-left: 1px solid var(--border); color: var(--muted-strong); font-style: normal; }
|
||||
.recording-pipeline { border-right: 1px solid var(--border); }
|
||||
.recording-pipeline > header > div { min-width: 0; }
|
||||
.recording-pipeline > header > div strong, .recording-pipeline > header > div span { display: block; }
|
||||
.recording-pipeline > header > div span { margin-top: 1px; color: var(--muted); font-size: var(--text-xs); }
|
||||
.recording-pipeline > header > i { display: inline-flex; align-items: center; gap: 4px; color: var(--success); font-size: var(--text-xs); font-style: normal; white-space: nowrap; }
|
||||
.recording-pipeline__body { max-height: 700px; padding: 13px 13px 18px 10px; overflow: auto; }
|
||||
.recording-pipeline-step { position: relative; display: grid; grid-template-columns: 31px minmax(0, 1fr); align-items: stretch; }
|
||||
.recording-step-rail { min-height: 73px; display: grid; grid-template-rows: 30px minmax(0, 1fr); justify-items: center; color: var(--muted); }
|
||||
.recording-step-rail > i { position: relative; z-index: 1; width: 25px; height: 25px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 50%; background: var(--surface); color: var(--muted-strong); font: 700 9px/1 var(--font-mono); font-style: normal; }
|
||||
.recording-step-rail > span { width: 1px; min-height: 43px; display: grid; align-items: end; justify-items: center; background: var(--border); color: var(--muted); }
|
||||
.recording-step-rail > span svg { width: 11px; margin: 0 0 -5px; padding: 1px 0; background: var(--background); }
|
||||
.recording-pipeline-step.is-navigation .recording-step-rail > i { border-color: color-mix(in srgb, var(--warning) 55%, var(--border)); background: var(--warning-soft); color: var(--warning); }
|
||||
.recording-pipeline-step > button { width: 100%; min-height: 73px; padding: 10px; display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; gap: 10px; align-items: center; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); color: var(--foreground); text-align: left; cursor: pointer; transition: border-color .14s ease, background-color .14s ease, box-shadow .14s ease; }
|
||||
.recording-pipeline-step > button:hover { border-color: var(--border-strong); background: var(--surface-subtle); }
|
||||
.recording-pipeline-step > button.is-linked { border-left: 3px solid var(--success); }
|
||||
.recording-pipeline-step > button.is-selected { border-color: var(--primary); box-shadow: 0 0 0 2px var(--focus); }
|
||||
.recording-pipeline-step.is-navigation > button { margin: 4px 0 9px; border-style: dashed; border-color: color-mix(in srgb, var(--warning) 42%, var(--border)); background: color-mix(in srgb, var(--warning-soft) 45%, var(--surface)); }
|
||||
.recording-event-icon { width: 34px; height: 34px; display: grid; place-items: center; border-radius: var(--radius-sm); background: var(--surface-subtle); color: var(--muted-strong); }
|
||||
.recording-event-icon.kind-crypto { background: var(--warning-soft); color: var(--warning); }
|
||||
.recording-event-icon.kind-fetch, .recording-event-icon.kind-xhr, .recording-event-icon.kind-form { background: var(--primary-soft); color: var(--primary-text); }
|
||||
.recording-event-icon.kind-websocket { background: var(--success-soft); color: var(--success); }
|
||||
.recording-event-icon.kind-navigation { background: var(--warning-soft); color: var(--warning); }
|
||||
.recording-pipeline-step button > span:nth-child(2) { min-width: 0; }
|
||||
.recording-pipeline-step small, .recording-pipeline-step strong, .recording-pipeline-step em, .recording-pipeline-step b { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.recording-pipeline-step small { color: var(--muted); font-size: 10px; font-weight: 650; }
|
||||
.recording-pipeline-step strong { margin-top: 2px; font-size: var(--text-sm); font-weight: 650; }
|
||||
.recording-pipeline-step em { margin-top: 2px; color: var(--muted); font-size: var(--text-xs); font-style: normal; }
|
||||
.recording-pipeline-step b { margin-top: 4px; color: var(--warning); font-size: 10px; font-weight: 650; }
|
||||
.recording-event-meta { display: grid; justify-items: end; gap: 5px; }
|
||||
.recording-event-meta i { padding: 2px 6px; border-radius: 999px; background: var(--warning-soft); color: var(--warning); font-size: 10px; font-style: normal; white-space: nowrap; }
|
||||
.recording-event-meta i.is-history { background: var(--surface-strong); color: var(--muted); }
|
||||
.recording-event-meta time { color: var(--muted); font-size: var(--text-xs); white-space: nowrap; }
|
||||
.recording-event-meta small { color: var(--muted); font-size: 10px; font-weight: 500; white-space: nowrap; }
|
||||
.recording-inspector > dl.recording-navigation-detail { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.recording-inspector > dl.recording-navigation-detail div:nth-child(odd) { border-left: 0; }
|
||||
.recording-inspector > dl.recording-navigation-detail div:nth-child(n + 3) { border-top: 1px solid var(--border); }
|
||||
.recording-navigation-detail dd { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.recording-column-empty { min-height: 180px; padding: 24px; display: grid; place-items: center; color: var(--muted); font-size: var(--text-sm); text-align: center; }
|
||||
.recording-inspector { max-height: 746px; padding: 14px; overflow: auto; display: grid; gap: 14px; align-content: start; }
|
||||
.recording-inspector > header { min-width: 0; display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
||||
.recording-inspector > header > div { min-width: 0; }
|
||||
.recording-inspector > header span, .recording-inspector > header strong, .recording-inspector > header small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.recording-inspector > header span { color: var(--primary); font-size: var(--text-xs); font-weight: 700; }
|
||||
.recording-inspector > header strong { margin-top: 3px; font-size: var(--text-lg); }
|
||||
.recording-inspector > header small { margin-top: 3px; color: var(--muted); font-size: var(--text-xs); }
|
||||
.recording-inspector > header > i { flex: 0 0 auto; color: var(--muted); font-size: var(--text-xs); font-style: normal; }
|
||||
.recording-inspector > header > i.is-error { color: var(--danger); font-weight: 700; }
|
||||
.recording-inspector > dl { margin: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
||||
.recording-inspector > dl div { min-width: 0; padding: 9px 5px; text-align: center; }
|
||||
.recording-inspector > dl div + div { border-left: 1px solid var(--border); }
|
||||
.recording-inspector dt { color: var(--muted); font-size: 10px; }
|
||||
.recording-inspector dd { margin: 3px 0 0; overflow: hidden; font-size: var(--text-sm); font-weight: 650; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.recording-values { display: grid; gap: 6px; }
|
||||
.recording-values > strong { font-size: var(--text-sm); }
|
||||
.recording-values pre, .recording-evidence pre { max-height: 160px; }
|
||||
.recording-evidence { border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
||||
.recording-evidence summary { padding: 9px 0; color: var(--muted-strong); font-size: var(--text-sm); font-weight: 600; cursor: pointer; }
|
||||
.recording-evidence[open] { padding-bottom: 10px; }
|
||||
.profile-inference { padding: 11px 0 0; display: grid; gap: 10px; border-top: 2px solid var(--primary); }
|
||||
.profile-inference.is-medium { border-top-color: var(--warning); }
|
||||
.profile-inference.is-low { border-top-color: var(--border-strong); }
|
||||
.profile-inference__heading { min-width: 0; display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; align-items: start; gap: 8px; }
|
||||
.profile-inference__mark { width: 28px; height: 28px; display: grid; place-items: center; border-radius: var(--radius-sm); background: var(--primary-soft); color: var(--primary); }
|
||||
.profile-inference__heading > span:nth-child(2) { min-width: 0; }
|
||||
.profile-inference__heading small, .profile-inference__heading strong { display: block; }
|
||||
.profile-inference__heading small { color: var(--primary-text); font-size: var(--text-xs); font-weight: 700; }
|
||||
.profile-inference__heading strong { margin-top: 2px; font-size: var(--text-sm); line-height: 1.45; }
|
||||
.profile-inference__heading > i { min-height: 23px; padding: 0 7px; display: inline-flex; align-items: center; gap: 4px; border: 1px solid color-mix(in srgb, var(--success) 34%, var(--border)); border-radius: 999px; color: var(--success); font-size: 10px; font-style: normal; font-weight: 650; white-space: nowrap; }
|
||||
.profile-inference.is-medium .profile-inference__heading > i { border-color: color-mix(in srgb, var(--warning) 34%, var(--border)); color: var(--warning); }
|
||||
.profile-inference.is-low .profile-inference__heading > i { border-color: var(--border); color: var(--muted-strong); }
|
||||
.profile-inference__flow { min-width: 0; padding: 8px 9px; display: flex; align-items: center; flex-wrap: wrap; gap: 4px; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); background: var(--surface-subtle); }
|
||||
.profile-inference__flow > span { min-width: 0; display: inline-flex; align-items: center; gap: 4px; color: var(--muted); }
|
||||
.profile-inference__flow code { max-width: 210px; overflow: hidden; color: var(--foreground); font-size: 10px; font-weight: 600; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.profile-inference__sources { display: grid; gap: 5px; }
|
||||
.profile-inference__sources > div { min-width: 0; padding: 7px 8px; display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: 2px 7px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-subtle); }
|
||||
.profile-inference__sources span { grid-row: span 2; width: 21px; height: 21px; display: grid; place-items: center; border-radius: 50%; background: var(--warning-soft); color: var(--warning); font-size: 9px; font-weight: 700; }
|
||||
.profile-inference__sources strong, .profile-inference__sources small { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.profile-inference__sources strong { font-size: var(--text-xs); }
|
||||
.profile-inference__sources small { color: var(--muted); font-size: 10px; }
|
||||
.profile-inference__arguments { margin: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px 12px; }
|
||||
.profile-inference__arguments > div { min-width: 0; }
|
||||
.profile-inference__arguments dt { color: var(--muted); font-size: 10px; }
|
||||
.profile-inference__arguments dd { margin: 2px 0 0; overflow: hidden; font-size: var(--text-xs); font-weight: 600; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.profile-inference__evidence { border-top: 1px solid var(--border); }
|
||||
.profile-inference__evidence summary { padding-top: 8px; color: var(--muted-strong); font-size: var(--text-xs); font-weight: 650; cursor: pointer; }
|
||||
.profile-inference__evidence ol { margin: 8px 0 0; padding: 0; display: grid; gap: 6px; list-style: none; }
|
||||
.profile-inference__evidence li { display: grid; grid-template-columns: 7px minmax(0, 1fr); align-items: start; gap: 7px; color: var(--muted-strong); font-size: var(--text-xs); line-height: 1.45; }
|
||||
.profile-inference__evidence li > i { width: 7px; height: 7px; margin-top: 4px; border-radius: 50%; background: var(--border-strong); }
|
||||
.profile-inference__evidence li[data-strength='proven'] > i { background: var(--success); }
|
||||
.profile-inference__evidence li[data-strength='supported'] > i { background: var(--primary); }
|
||||
.profile-inference__next { padding: 9px 0 0; display: grid; gap: 9px; border-top: 1px solid var(--border); }
|
||||
.profile-inference__next > span { color: var(--muted-strong); font-size: var(--text-xs); line-height: 1.5; }
|
||||
.profile-inference__next .ui-button { justify-self: stretch; }
|
||||
.recording-deep-action { padding: 11px; display: grid; gap: 10px; border: 1px solid color-mix(in srgb, var(--primary) 34%, var(--border)); border-radius: var(--radius-md); background: var(--primary-soft); }
|
||||
.recording-deep-action > div:first-child { display: flex; align-items: center; gap: 8px; color: var(--primary); }
|
||||
.recording-deep-action > div:first-child span { min-width: 0; }
|
||||
.recording-deep-action strong, .recording-deep-action small { display: block; }
|
||||
.recording-deep-action strong { color: var(--foreground); font-size: var(--text-sm); }
|
||||
.recording-deep-action small { margin-top: 2px; color: var(--muted-strong); font-size: var(--text-xs); }
|
||||
.recording-recipe-action { padding: 11px; display: grid; gap: 10px; border: 1px solid color-mix(in srgb, var(--warning) 34%, var(--border)); border-radius: var(--radius-md); background: var(--warning-soft); }
|
||||
.recording-recipe-action > div:first-child { display: flex; align-items: center; gap: 8px; color: var(--warning); }
|
||||
.recording-recipe-action > div:first-child span { min-width: 0; }
|
||||
.recording-recipe-action strong, .recording-recipe-action small { display: block; }
|
||||
.recording-recipe-action strong { color: var(--foreground); font-size: var(--text-sm); }
|
||||
.recording-recipe-action small { margin-top: 2px; color: var(--muted-strong); font-size: var(--text-xs); }
|
||||
.recording-recipe-editor { display: grid; gap: 9px; }
|
||||
.recording-recipe-editor > label, .recording-recipe-editor > div:not(.recording-recipe-editor__actions) { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||
.recording-recipe-editor > label { grid-template-columns: minmax(0, 1fr); }
|
||||
.recording-recipe-editor label { min-width: 0; display: grid; gap: 4px; }
|
||||
.recording-recipe-editor label > span { color: var(--muted-strong); font-size: var(--text-xs); font-weight: 600; }
|
||||
.recording-recipe-editor__actions, .recording-recipe-buttons { display: flex; justify-content: flex-end; gap: 7px; }
|
||||
.recording-recipes { padding-top: 13px; display: grid; gap: 9px; border-top: 1px solid var(--border); }
|
||||
.recording-recipes__heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.recording-recipes__heading strong { font-size: var(--text-sm); }
|
||||
.recording-recipes__heading select { width: min(190px, 58%); }
|
||||
.recording-recipe-meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--muted-strong); font-size: var(--text-xs); }
|
||||
.recording-recipe-meta i { color: var(--primary-text); font-style: normal; }
|
||||
.recording-recipes textarea { min-height: 82px; resize: vertical; font-family: var(--font-mono); font-size: var(--text-sm); }
|
||||
.recording-recipe-result { display: grid; gap: 6px; }
|
||||
.recording-recipe-result > div { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto 34px; align-items: center; gap: 7px; }
|
||||
.recording-recipe-result strong { overflow: hidden; font-size: var(--text-sm); white-space: nowrap; text-overflow: ellipsis; }
|
||||
.recording-recipe-result span { color: var(--muted); font-size: var(--text-xs); white-space: nowrap; }
|
||||
.recording-recipe-result pre { max-height: 190px; }
|
||||
|
||||
/* ---------- 登录态工作区 ---------- */
|
||||
.context-options { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
|
||||
@@ -584,18 +744,29 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0;
|
||||
/* ---------- 窄屏适配 ---------- */
|
||||
@media (max-width: 1080px) {
|
||||
.task-status-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.network-layout, .observation-layout, .context-workspace, .engine-layout, .rule-layout, .cookie-layout, .split-view { grid-template-columns: minmax(0, 1fr); }
|
||||
.network-layout, .context-workspace, .engine-layout, .rule-layout, .cookie-layout, .split-view { grid-template-columns: minmax(0, 1fr); }
|
||||
.network-inspector, .context-inspector, .cookie-editor-pane, .protocol-panel { position: static; }
|
||||
.recording-workbench { grid-template-columns: 220px minmax(0, 1fr); }
|
||||
.recording-inspector { grid-column: 1 / -1; max-height: none; border-top: 1px solid var(--border); }
|
||||
.recording-pipeline { border-right: 0; }
|
||||
.proxy-tools { grid-template-columns: minmax(0, 1fr); }
|
||||
.bridge-identity-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.ua-management { grid-template-columns: minmax(0, 1fr); }
|
||||
.ua-assignments { border-right: 0; border-bottom: 1px solid var(--border); }
|
||||
.ua-current-site { grid-template-columns: minmax(200px, .8fr) minmax(240px, 1fr); }
|
||||
.ua-current-site > .editor-actions { grid-column: 1 / 3; justify-content: flex-end; }
|
||||
.recording-heading { grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.recording-heading__actions { grid-column: 1 / -1; min-width: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.app-shell { grid-template-columns: minmax(0, 1fr); }
|
||||
.sidebar { position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--border); }
|
||||
.sidebar-brand { height: 56px; }
|
||||
.sidebar nav { grid-auto-flow: column; grid-auto-columns: max-content; overflow-x: auto; padding: 10px; }
|
||||
.sidebar nav button { width: auto; grid-template-columns: 18px 1fr; }
|
||||
.sidebar nav { display: flex; overflow-x: auto; overflow-y: hidden; padding: 10px; }
|
||||
.sidebar-group { display: contents; }
|
||||
.sidebar-group__label { display: none; }
|
||||
.sidebar nav button { width: auto; flex: 0 0 auto; grid-template-columns: 18px max-content; white-space: nowrap; }
|
||||
.sidebar nav button > svg:last-child { display: none; }
|
||||
.sidebar-theme { margin-top: 0; grid-auto-flow: column; align-items: center; justify-content: space-between; }
|
||||
.sidebar-theme select { width: 150px; }
|
||||
@@ -603,6 +774,11 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0;
|
||||
.topbar { padding: 0 16px; }
|
||||
.content-area { padding: 16px; }
|
||||
.page-heading { flex-direction: column; align-items: flex-start; }
|
||||
.ua-current-site { grid-template-columns: minmax(0, 1fr); }
|
||||
.ua-current-site > .editor-actions { grid-column: auto; justify-content: stretch; }
|
||||
.ua-current-site > .editor-actions .ui-button { flex: 1; }
|
||||
.ua-assignment-list > div { grid-template-columns: minmax(0, 1fr) 34px; }
|
||||
.ua-assignment-list code { display: none; }
|
||||
.task-command-bar, .agent-runtime-summary { flex-direction: column; display: flex; align-items: stretch; }
|
||||
.task-status-grid, .context-session-strip, .diff-summary, .context-inventory-grid, .grant-options, .panel-policy-grid, .form-grid, .paired-engine-meta { grid-template-columns: minmax(0, 1fr); }
|
||||
.agent-action-row { grid-template-columns: 12px 76px minmax(0, 1fr) 76px; }
|
||||
@@ -612,9 +788,18 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0;
|
||||
.activity-table__row > span:nth-child(2), .activity-table__row > span:nth-child(4), .activity-table__row > span:last-child { display: none; }
|
||||
.network-table-head, .network-row { grid-template-columns: 56px 50px minmax(0, 1fr) 66px; }
|
||||
.network-table-head span:nth-child(4), .network-row > span:nth-child(4) { display: none; }
|
||||
.observation-table-head, .observation-row { grid-template-columns: 76px minmax(0, 1fr) 80px; }
|
||||
.observation-table-head span:nth-child(2), .observation-table-head span:nth-child(4),
|
||||
.observation-row > span:nth-child(2), .observation-row > span:nth-child(4) { display: none; }
|
||||
.recording-heading { grid-template-columns: minmax(0, 1fr); align-items: flex-start; }
|
||||
.recording-mode-switch { justify-self: start; }
|
||||
.recording-heading__actions { grid-column: auto; justify-self: stretch; justify-content: flex-start; }
|
||||
.recording-controls { flex-wrap: wrap; }
|
||||
.recording-summary { width: 100%; order: 3; margin-left: 0; }
|
||||
.recording-navigation { grid-template-columns: 22px minmax(0, 1fr); }
|
||||
.recording-navigation > .ui-button { grid-column: 1 / -1; justify-self: stretch; }
|
||||
.recording-workbench { grid-template-columns: minmax(0, 1fr); }
|
||||
.recording-traces, .recording-pipeline { border-right: 0; border-bottom: 1px solid var(--border); }
|
||||
.recording-traces > div, .recording-pipeline__body { max-height: 420px; }
|
||||
.recording-inspector { grid-column: auto; border-top: 0; }
|
||||
.recording-event-meta i { display: none; }
|
||||
.cookie-columns { grid-template-columns: 24px minmax(0, 1fr) minmax(0, 1fr) 34px; }
|
||||
.cookie-columns > span:nth-child(4), .cookie-columns > span:nth-child(5) { display: none; }
|
||||
.cookie-toolbar select { min-width: 0; flex: 1; }
|
||||
|
||||
+102
-210
@@ -1,11 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { browser, type Browser } from 'wxt/browser';
|
||||
import {
|
||||
Activity, AlertTriangle, Bot, Braces, Check, ChevronRight, CircleGauge, Cookie, Copy,
|
||||
Database, Download, Eye, EyeOff, GripVertical, History, KeyRound, MousePointer2, Network, Play, Plus, Power, Radio,
|
||||
RefreshCw, Route, Save, Search, Send, Server, ShieldCheck, Square, Trash2, Upload, UserRoundCog, X,
|
||||
Activity, AlertTriangle, Bot, Braces, Check, ChevronRight, CircleGauge, CloudDownload, Cookie, Copy,
|
||||
Database, Download, Eye, History, KeyRound, MousePointer2, Network, Play, Power, Radio,
|
||||
RefreshCw, Route, Save, Search, Send, Server, ShieldCheck, Square, Trash2, Upload, UserRoundCog, Wrench, X,
|
||||
} from 'lucide-react';
|
||||
import { v7 as uuidv7 } from 'uuid';
|
||||
import { ProductBrand, YakitMark } from '@/components/brand/Brand';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field } from '@/components/ui/field';
|
||||
@@ -14,38 +13,54 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
AUDIT_CATEGORY_LABELS, AUDIT_OUTCOME_LABELS, HANDOFF_REASON_LABELS, waitingHandoff,
|
||||
} from '@/features/handoff/presentation';
|
||||
import { cookieKey, cookieRemovalInput } from '@/features/cookies/presentation';
|
||||
import { AutoSwitchView } from '@/features/proxy/ui/AutoSwitchView';
|
||||
import { ProxyProfilesView } from '@/features/proxy/ui/ProxyProfilesView';
|
||||
import { RuleSourcesView } from '@/features/proxy/ui/RuleSourcesView';
|
||||
import { RecordingWorkspace } from '@/features/browser-recording/RecordingWorkspace';
|
||||
import { CAPABILITY_LABELS, CONTROL_CAPABILITY_SCOPES, READ_CAPABILITY_SCOPES, isControlScopeSet } from '@/protocol/capabilities';
|
||||
import { AGENT_RUNTIME_STORAGE_KEY, AUDIT_STORAGE_KEY, isStateStorageChange } from '@/protocol/storage';
|
||||
import type {
|
||||
ActiveTabInfo, AgentRuntime, AuditEvent, BridgePairingStatus, BridgeStatus, BrowserCookie, BrowserRequestAnalysisBundle, CookieInput, CookieTransferFormat, EnterprisePolicyStatus, ExtensionState, HumanHandoff,
|
||||
NetworkCaptureStatus, NetworkRequestExport, NetworkRequestRecord, PageContext, PageEvalResult,
|
||||
PageFrameSummary, PageNodeDetails, PageNodeSummary, PageObservationRecord, PageObservationStatus,
|
||||
ProxyConfiguration, ProxyProfile, ProxyRule, ProxyRulePreview, ProxyRuleStats, UserAgentRule, YakPocGenerateResult,
|
||||
PageFrameSummary, PageNodeDetails, PageNodeSummary,
|
||||
UserAgentProfile, UserAgentProfileInput, YakPocGenerateResult,
|
||||
} from '@/types/models';
|
||||
import { errorMessage, request } from '@/platform/messaging/runtime';
|
||||
import { APPEARANCE_STORAGE_KEY, getAppearance, setThemePreference, type ThemePreference } from '@/platform/storage/appearance';
|
||||
import './App.css';
|
||||
|
||||
type Section = 'overview' | 'proxies' | 'rules' | 'cookies' | 'user-agent' | 'network' | 'context' | 'engine' | 'activity';
|
||||
type Section = 'overview' | 'proxies' | 'rules' | 'sources' | 'cookies' | 'user-agent' | 'network' | 'context' | 'engine' | 'activity';
|
||||
const FIREFOX_AMO_BUILD = import.meta.env.FIREFOX && import.meta.env.MODE === 'store';
|
||||
|
||||
const SECTIONS: Array<{ id: Section; label: string; icon: ReactNode }> = [
|
||||
{ id: 'overview', label: '运行概览', icon: <CircleGauge size={17} /> },
|
||||
{ id: 'proxies', label: '代理配置', icon: <Network size={17} /> },
|
||||
{ id: 'rules', label: '代理规则', icon: <Route size={17} /> },
|
||||
{ id: 'cookies', label: 'Cookie Editor', icon: <Cookie size={17} /> },
|
||||
{ id: 'user-agent', label: 'UA 请求头', icon: <UserRoundCog size={17} /> },
|
||||
{ id: 'network', label: '网络活动', icon: <Activity size={17} /> },
|
||||
{ id: 'context', label: '登录态工作区', icon: <KeyRound size={17} /> },
|
||||
{ id: 'engine', label: '引擎连接', icon: <Server size={17} /> },
|
||||
{ id: 'activity', label: '操作记录', icon: <History size={17} /> },
|
||||
const NAVIGATION: Array<{ label: string; icon?: ReactNode; items: Array<{ id: Section; label: string; icon: ReactNode }> }> = [
|
||||
{ label: '工作区', items: [{ id: 'overview', label: '运行概览', icon: <CircleGauge size={17} /> }] },
|
||||
{
|
||||
label: '网络与流量',
|
||||
items: [
|
||||
{ id: 'proxies', label: '代理出口', icon: <Network size={17} /> },
|
||||
{ id: 'rules', label: '自动切换', icon: <Route size={17} /> },
|
||||
{ id: 'sources', label: '规则订阅', icon: <CloudDownload size={17} /> },
|
||||
{ id: 'network', label: '网络活动', icon: <Activity size={17} /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '常用工具', icon: <Wrench size={13} />,
|
||||
items: [
|
||||
{ id: 'cookies', label: 'Cookie Editor', icon: <Cookie size={17} /> },
|
||||
{ id: 'user-agent', label: 'UA 快速切换', icon: <UserRoundCog size={17} /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Agent 与系统',
|
||||
items: [
|
||||
{ id: 'context', label: '登录态工作区', icon: <KeyRound size={17} /> },
|
||||
{ id: 'engine', label: '引擎连接', icon: <Server size={17} /> },
|
||||
{ id: 'activity', label: '操作记录', icon: <History size={17} /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const UA_PRESETS = [
|
||||
['Chrome / Windows', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'],
|
||||
['Safari / iPhone', 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1'],
|
||||
['Googlebot', 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'],
|
||||
] as const;
|
||||
const SECTIONS = NAVIGATION.flatMap((group) => group.items);
|
||||
|
||||
const CONTEXT_SECTION_LABELS: Record<PageContext['diff']['changedSections'][number], string> = {
|
||||
capture_options: '采集范围',
|
||||
@@ -183,7 +198,7 @@ function App() {
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-brand"><ProductBrand /></div>
|
||||
<nav>{SECTIONS.map((item) => <button key={item.id} className={section === item.id ? 'active' : ''} onClick={() => navigate(item.id)}>{item.icon}<span>{item.label}</span><ChevronRight size={14} /></button>)}</nav>
|
||||
<nav>{NAVIGATION.map((group) => <div className="sidebar-group" key={group.label}><span className="sidebar-group__label">{group.icon}{group.label}</span>{group.items.map((item) => <button key={item.id} className={section === item.id ? 'active' : ''} onClick={() => navigate(item.id)}>{item.icon}<span>{item.label}</span><ChevronRight size={14} /></button>)}</div>)}</nav>
|
||||
<div className="sidebar-theme">
|
||||
<span>外观</span>
|
||||
<select aria-label="界面主题" value={theme} onChange={(event) => { const next = event.target.value as ThemePreference; setTheme(next); void setThemePreference(next); }}>
|
||||
@@ -208,10 +223,11 @@ function App() {
|
||||
|
||||
<div className="content-area">
|
||||
{section === 'overview' && <Overview state={state} bridge={bridge} tab={tab} navigate={navigate} run={run} busy={busy} />}
|
||||
{section === 'proxies' && <ProxyProfiles state={state} setState={setState} run={run} busy={busy} />}
|
||||
{section === 'rules' && <ProxyRules state={state} setState={setState} tab={tab} run={run} busy={busy} />}
|
||||
{section === 'proxies' && <ProxyProfilesView state={state} setState={setState} run={run} busy={busy} tab={tab} />}
|
||||
{section === 'rules' && <AutoSwitchView state={state} setState={setState} tab={tab} run={run} busy={busy} />}
|
||||
{section === 'sources' && <RuleSourcesView state={state} setState={setState} tab={tab} run={run} busy={busy} />}
|
||||
{section === 'cookies' && <CookieEditor key={tab?.id || 0} tab={tab} run={run} busy={busy} />}
|
||||
{section === 'user-agent' && <UserAgents state={state} setState={setState} run={run} busy={busy} />}
|
||||
{section === 'user-agent' && <UserAgents state={state} setState={setState} tab={tab} run={run} busy={busy} />}
|
||||
{section === 'network' && <NetworkActivity key={tab?.id || 0} tab={tab} bridge={bridge} run={run} busy={busy} />}
|
||||
{section === 'context' && <ContextTool key={tab?.id || 0} tab={tab} run={run} busy={busy} />}
|
||||
{section === 'engine' && <EngineSettings state={state} setState={setState} bridge={bridge} setBridge={setBridge} tabs={tabs} run={run} busy={busy} />}
|
||||
@@ -304,7 +320,7 @@ function ActivityLog({ run, busy }: { run: (task: () => Promise<void>, success?:
|
||||
}
|
||||
|
||||
function Overview({ state, bridge, tab, navigate, run, busy }: { state: ExtensionState; bridge: BridgeStatus; tab?: ActiveTabInfo; navigate: (value: Section) => void; run: (task: () => Promise<void>, success?: string) => Promise<void>; busy: boolean }) {
|
||||
const activeProxy = state.proxyProfiles.find((profile) => profile.id === state.activeProxyId)?.name || (state.activeProxyId === 'rules' ? '按规则分流' : '未知');
|
||||
const activeProxy = state.proxyProfiles.find((profile) => profile.id === state.activeProxyId)?.name || (state.activeProxyId === 'auto' ? '自动切换' : '未知');
|
||||
const [runtime, setRuntime] = useState<AgentRuntime>({ state: 'idle', updatedAt: Date.now(), actions: [] });
|
||||
const [network, setNetwork] = useState<NetworkCaptureStatus>();
|
||||
const [loginContext, setLoginContext] = useState<PageContext>();
|
||||
@@ -338,99 +354,18 @@ function Overview({ state, bridge, tab, navigate, run, busy }: { state: Extensio
|
||||
</div>
|
||||
<div className="task-status-grid">
|
||||
<section><span>浏览器现场</span><strong>{loginContext ? `${loginContext.document?.forms.length || 0} 表单 / ${loginContext.document?.interactive.length || 0} 节点` : '尚未采集'}</strong><small>{loginContext?.authentication.evidence[0] || 'Cookie、Storage 与认证信号仅在用户点击后读取'}</small><button onClick={() => navigate('context')}>打开上下文<ChevronRight size={15} /></button></section>
|
||||
<section><span>代理与流量</span><strong>{activeProxy}</strong><small>{network?.active ? `${network.count} 条请求,${network.droppedCount} 条丢弃` : `${state.proxyRules.filter((rule) => rule.enabled).length} 条分流规则 · 捕获未启动`}</small><button onClick={() => navigate(network?.active ? 'network' : 'rules')}>查看流量策略<ChevronRight size={15} /></button></section>
|
||||
<section><span>代理与流量</span><strong>{activeProxy}</strong><small>{network?.active ? `${network.count} 条请求,${network.droppedCount} 条丢弃` : `${state.proxyRules.filter((rule) => rule.enabled).length} 条手动规则 · ${state.proxyRuleSources.filter((source) => source.enabled).length} 个订阅源`}</small><button onClick={() => navigate(network?.active ? 'network' : 'rules')}>查看流量策略<ChevronRight size={15} /></button></section>
|
||||
<section><span>Agent 会话</span><strong>{state.activeGrant ? `${isControlScopeSet(state.activeGrant.scopes) ? '控制' : '只读'} · ${runtime.state}` : '未共享'}</strong><small>{state.activeGrant ? `${state.activeGrant.targets.length} 个 frame · ${new Date(state.activeGrant.expiresAt).toLocaleTimeString()} 到期` : '创建 task-bound grant 后才允许远程读取'}</small><button onClick={() => navigate('activity')}>查看动作时间线<ChevronRight size={15} /></button></section>
|
||||
<section className={state.handoff?.state === 'waiting_for_user' ? 'needs-attention' : ''}><span>需要用户处理</span><strong>{state.handoff?.state === 'waiting_for_user' ? HANDOFF_REASON_LABELS[state.handoff.reason] : runtime.state === 'paused' ? 'Agent 已暂停' : '没有待办步骤'}</strong><small>{state.handoff?.state === 'waiting_for_user' ? state.handoff.message : latestAction ? `最近 ${latestAction.method} · ${latestAction.state}` : '二维码、MFA 与 CAPTCHA 会在这里出现'}</small><button onClick={() => navigate('activity')}>会话控制<ChevronRight size={15} /></button></section>
|
||||
</div>
|
||||
<div className="task-workflow-list">
|
||||
<button onClick={() => navigate('cookies')}><Cookie size={18} /><span><strong>检查 Cookie 与登录线索</strong><small>值默认隐藏,导出默认脱敏。</small></span><ChevronRight size={16} /></button>
|
||||
<button onClick={() => navigate('cookies')}><Cookie size={18} /><span><strong>检查 Cookie 与登录线索</strong><small>直接检查原始值,导出默认脱敏。</small></span><ChevronRight size={16} /></button>
|
||||
<button onClick={() => navigate('network')}><Send size={18} /><span><strong>请求转到 Yakit</strong><small>选择捕获记录后打开 Web Fuzzer、生成 Yak PoC 或准备 AI 分析。</small></span><ChevronRight size={16} /></button>
|
||||
<button onClick={() => navigate('context')}><Braces size={18} /><span><strong>观测签名与加解密</strong><small>短时观测 WebCrypto、CryptoJS、WebSocket 和请求调用栈。</small></span><ChevronRight size={16} /></button>
|
||||
<button onClick={() => navigate('context')}><Braces size={18} /><span><strong>观测签名与加解密</strong><small>短时观测 WebCrypto、CryptoJS、JSEncrypt、WebSocket 和请求调用栈。</small></span><ChevronRight size={16} /></button>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ProxyProfiles({ state, setState, run, busy }: { state: ExtensionState; setState: (state: ExtensionState) => void; run: (task: () => Promise<void>, success?: string) => Promise<void>; busy: boolean }) {
|
||||
const empty: ProxyProfile = { id: '', name: '', kind: 'fixed_servers', scheme: 'http', host: '127.0.0.1', port: 8083, bypass: ['localhost', '127.0.0.1', '<local>'] };
|
||||
const [draft, setDraft] = useState<ProxyProfile>();
|
||||
const [authPassword, setAuthPassword] = useState('');
|
||||
const [authConfigured, setAuthConfigured] = useState(false);
|
||||
const selectDraft = (profile: ProxyProfile) => {
|
||||
setDraft(profile);
|
||||
setAuthPassword('');
|
||||
void request('proxy.auth.status', { profileId: profile.id }).then((result) => setAuthConfigured(result.configured));
|
||||
};
|
||||
const saveProfile = () => run(async () => {
|
||||
if (!draft) return;
|
||||
setState(await request('proxy.save', draft));
|
||||
if (draft.authEnabled && authPassword) {
|
||||
const result = await request('proxy.auth.set', { profileId: draft.id, password: authPassword });
|
||||
setAuthConfigured(result.configured);
|
||||
setAuthPassword('');
|
||||
} else if (!draft.authEnabled) {
|
||||
await request('proxy.auth.set', { profileId: draft.id, password: '' });
|
||||
setAuthConfigured(false);
|
||||
}
|
||||
}, '代理配置已保存');
|
||||
return <div className="section-view split-view">
|
||||
<div className="list-pane">
|
||||
<div className="page-heading"><div><h1>代理配置</h1><p>固定代理、SOCKS、PAC 与会话级认证出口。</p></div><button className="primary-button" onClick={() => { setDraft({ ...empty, id: uuidv7() }); setAuthConfigured(false); setAuthPassword(''); }}><Plus size={16} />新建代理</button></div>
|
||||
<div className="data-list">{state.proxyProfiles.map((profile) => <button key={profile.id} className={`data-row ${state.activeProxyId === profile.id ? 'selected' : ''}`} onClick={() => selectDraft(profile)}><span className="row-icon"><Network size={16} /></span><span><strong>{profile.name}</strong><small>{profile.kind === 'fixed_servers' ? `${profile.scheme}://${profile.host}:${profile.port}` : profile.kind}</small></span>{profile.authEnabled && <span className="active-label">认证</span>}{state.activeProxyId === profile.id && <span className="active-label">使用中</span>}<ChevronRight size={15} /></button>)}</div>
|
||||
</div>
|
||||
<div className="editor-pane">{draft ? <>
|
||||
<div className="editor-heading"><div><h2>{draft.builtin ? '内置代理' : '编辑代理'}</h2><p>{draft.id}</p></div><button className="icon-button" title="关闭编辑" onClick={() => setDraft(undefined)}><X size={17} /></button></div>
|
||||
<div className="form-grid">
|
||||
<Field label="名称"><input value={draft.name} disabled={draft.builtin} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></Field>
|
||||
<Field label="类型"><select value={draft.kind} disabled={draft.builtin} onChange={(event) => setDraft({ ...draft, kind: event.target.value as ProxyProfile['kind'] })}><option value="fixed_servers">固定代理</option><option value="pac_script">PAC Script</option><option value="direct">直接连接</option><option value="system">系统代理</option></select></Field>
|
||||
{draft.kind === 'fixed_servers' && <><Field label="协议"><select value={draft.scheme} onChange={(event) => setDraft({ ...draft, scheme: event.target.value as ProxyProfile['scheme'] })}><option value="http">HTTP</option><option value="https">HTTPS</option><option value="socks4">SOCKS4</option><option value="socks5">SOCKS5</option></select></Field><Field label="主机"><input value={draft.host || ''} onChange={(event) => setDraft({ ...draft, host: event.target.value })} /></Field><Field label="端口"><input type="number" min="1" max="65535" value={draft.port || ''} onChange={(event) => setDraft({ ...draft, port: Number(event.target.value) })} /></Field><Field label="绕过地址" hint="每行一个域名、IP 或 <local>"><textarea rows={5} value={draft.bypass.join('\n')} onChange={(event) => setDraft({ ...draft, bypass: event.target.value.split('\n').map((item) => item.trim()).filter(Boolean) })} /></Field><label className="check-row"><input type="checkbox" checked={draft.authEnabled || false} onChange={(event) => setDraft({ ...draft, authEnabled: event.target.checked })} />代理认证</label>{draft.authEnabled && <><Field label="用户名"><input value={draft.authUsername || ''} onChange={(event) => setDraft({ ...draft, authUsername: event.target.value })} /></Field><Field label="会话密码" hint={authConfigured ? '已配置;留空保持当前密码' : '仅保存在当前浏览器会话'}><input type="password" autoComplete="new-password" value={authPassword} onChange={(event) => setAuthPassword(event.target.value)} /></Field></>}</>}
|
||||
{draft.kind === 'pac_script' && <><Field label="PAC URL"><input value={draft.pacUrl || ''} onChange={(event) => setDraft({ ...draft, pacUrl: event.target.value })} placeholder="https://example.test/proxy.pac" /></Field><Field label="内联 PAC"><textarea rows={10} value={draft.pacScript || ''} onChange={(event) => setDraft({ ...draft, pacScript: event.target.value })} /></Field></>}
|
||||
</div>
|
||||
<div className="editor-actions"><button className="primary-button" disabled={busy || !draft.name || Boolean(draft.authEnabled && !draft.authUsername)} onClick={() => void saveProfile()}><Save size={16} />保存</button><button disabled={busy} onClick={() => void run(async () => setState(await request('proxy.switch', { id: draft.id })), '代理已切换')}><Power size={16} />立即使用</button>{!draft.builtin && <button className="danger-button" disabled={busy} onClick={() => void run(async () => { setState(await request('proxy.delete', { id: draft.id })); await request('proxy.auth.set', { profileId: draft.id, password: '' }); setDraft(undefined); }, '代理已删除')}><Trash2 size={16} />删除</button>}</div>
|
||||
</> : <Empty>选择一个代理配置,或创建新的出口。</Empty>}</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ProxyRules({ state: fullState, setState, tab, run, busy }: { state: ExtensionState; setState: (state: ExtensionState) => void; tab?: ActiveTabInfo; run: (task: () => Promise<void>, success?: string) => Promise<void>; busy: boolean }) {
|
||||
const state = { ...fullState, proxyProfiles: fullState.proxyProfiles.filter((profile) => ['direct', 'fixed_servers'].includes(profile.kind)) };
|
||||
const freshRule = (): ProxyRule => ({ id: uuidv7(), name: '', enabled: true, patterns: [], proxyProfileId: state.proxyProfiles[2]?.id || 'direct', priority: Math.max(10, ...state.proxyRules.map((rule) => rule.priority + 10)) });
|
||||
const [draft, setDraft] = useState<ProxyRule>(freshRule);
|
||||
const [previewUrl, setPreviewUrl] = useState(tab?.url || 'https://example.com/');
|
||||
const [preview, setPreview] = useState<ProxyRulePreview>();
|
||||
const [stats, setStats] = useState<ProxyRuleStats[]>([]);
|
||||
const [pacScript, setPacScript] = useState('');
|
||||
const [configurationText, setConfigurationText] = useState('');
|
||||
const [draggedId, setDraggedId] = useState('');
|
||||
const orderedRules = [...state.proxyRules].sort((left, right) => right.priority - left.priority);
|
||||
const refreshOperationalData = useCallback(async () => {
|
||||
setStats(await request('proxy.rules.stats'));
|
||||
if (previewUrl.startsWith('http')) setPreview(await request('proxy.rules.preview', { url: previewUrl }));
|
||||
}, [previewUrl]);
|
||||
useEffect(() => { void refreshOperationalData(); }, [refreshOperationalData, state.proxyRules, state.proxyRouting]);
|
||||
const save = () => run(async () => { setState(await request('proxy.rule.save', draft)); setDraft(freshRule()); }, '代理规则已保存');
|
||||
const reorder = (sourceId: string, targetId: string) => run(async () => {
|
||||
const ids = orderedRules.map((rule) => rule.id);
|
||||
const source = ids.indexOf(sourceId);
|
||||
const target = ids.indexOf(targetId);
|
||||
if (source < 0 || target < 0 || source === target) return;
|
||||
ids.splice(target, 0, ids.splice(source, 1)[0]);
|
||||
setState(await request('proxy.rules.reorder', { ids }));
|
||||
}, '规则优先级已更新');
|
||||
const importConfiguration = () => run(async () => {
|
||||
const configuration = JSON.parse(configurationText) as ProxyConfiguration;
|
||||
setState(await request('proxy.config.import', { configuration }));
|
||||
}, '代理配置已导入');
|
||||
return <div className="section-view">
|
||||
<div className="page-heading"><div><h1>代理规则</h1><p>确定性优先级、冲突预览、PAC 路由与命中统计。</p></div><button className="primary-button" disabled={busy || state.proxyRules.length === 0} onClick={() => void run(async () => setState(await request('proxy.rules.apply')), '分流规则已应用')}><Route size={16} />应用规则</button></div>
|
||||
<div className="proxy-routing-bar"><Field label="默认出口"><select value={state.proxyRouting.defaultProfileId} onChange={(event) => void run(async () => setState(await request('proxy.rules.settings', { ...state.proxyRouting, defaultProfileId: event.target.value })), '默认出口已更新')}>{state.proxyProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}</select></Field><Field label="失败策略"><select value={state.proxyRouting.failMode} onChange={(event) => void run(async () => setState(await request('proxy.rules.settings', { ...state.proxyRouting, failMode: event.target.value as 'open' | 'closed' })), '失败策略已更新')}><option value="closed">Fail closed</option><option value="open">Fail open → DIRECT</option></select></Field><div className="proxy-preview-input"><label>当前 URL 预览</label><div><input value={previewUrl} onChange={(event) => setPreviewUrl(event.target.value)} /><Button size="icon" variant="ghost" title="刷新命中预览" aria-label="刷新命中预览" onClick={() => void refreshOperationalData()}><RefreshCw size={14} /></Button></div></div><div className={`proxy-preview-result ${preview?.conflict ? 'conflict' : ''}`}><small>{preview?.effectiveRuleId ? '命中规则' : '默认出口'}</small><strong>{state.proxyProfiles.find((profile) => profile.id === preview?.effectiveProfileId)?.name || '—'}</strong><span>{preview?.effectiveProxy || '—'}</span>{preview?.conflict && <i>多个出口冲突,使用最高优先级</i>}</div></div>
|
||||
<div className="rule-layout"><div className="rule-table proxy-rule-table"><div className="table-head proxy-rule-row"><span /><span>规则 / 优先级</span><span>匹配</span><span>出口</span><span>命中</span><span>状态</span><span /></div>{orderedRules.length === 0 ? <Empty>还没有代理分流规则。</Empty> : orderedRules.map((rule) => {
|
||||
const stat = stats.find((item) => item.ruleId === rule.id);
|
||||
return <div className="table-row proxy-rule-row" key={rule.id} draggable onDragStart={() => setDraggedId(rule.id)} onDragOver={(event) => event.preventDefault()} onDrop={() => void reorder(draggedId, rule.id)}><GripVertical size={15} /><button className="proxy-rule-name" onClick={() => setDraft(rule)}><strong>{rule.name}</strong><small>priority {rule.priority}</small></button><span title={rule.patterns.join(', ')}>{rule.patterns.join(', ')}</span><span>{state.proxyProfiles.find((profile) => profile.id === rule.proxyProfileId)?.name || '已删除'}</span><span title={stat?.lastUrl}>{stat?.hits || 0}</span><span className={rule.enabled ? 'status-good' : 'status-muted'}>{rule.enabled ? '启用' : '停用'}</span><button className="icon-button danger" title="删除规则" onClick={() => void run(async () => setState(await request('proxy.rule.delete', { id: rule.id })), '规则已删除')}><Trash2 size={15} /></button></div>;
|
||||
})}</div>
|
||||
<div className="rule-editor"><h2>{state.proxyRules.some((rule) => rule.id === draft.id) ? '编辑规则' : '新建规则'}</h2><Field label="名称"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} placeholder="登录域名走 MITM" /></Field><Field label="优先级"><input type="number" min="1" max="1000000" value={draft.priority} onChange={(event) => setDraft({ ...draft, priority: Number(event.target.value) })} /></Field><Field label="域名 / URL 模式" hint="每行一个,例如 *.example.com 或 https://api.example.com/*"><textarea rows={7} value={draft.patterns.join('\n')} onChange={(event) => setDraft({ ...draft, patterns: event.target.value.split('\n').map((item) => item.trim()).filter(Boolean) })} /></Field><Field label="代理出口"><select value={draft.proxyProfileId} onChange={(event) => setDraft({ ...draft, proxyProfileId: event.target.value })}>{state.proxyProfiles.map((profile) => <option value={profile.id} key={profile.id}>{profile.name}</option>)}</select></Field><label className="check-row"><input type="checkbox" checked={draft.enabled} onChange={(event) => setDraft({ ...draft, enabled: event.target.checked })} />启用这条规则</label><div className="editor-actions"><button className="primary-button" disabled={busy || !draft.name || draft.patterns.length === 0} onClick={() => void save()}><Save size={16} />保存规则</button><button onClick={() => setDraft(freshRule())}><Plus size={16} />新规则</button></div></div>
|
||||
</div>
|
||||
<div className="proxy-tools"><section><div><h2>PAC 编译结果</h2><Button variant="ghost" onClick={() => void run(async () => setPacScript(await request('proxy.rules.compile')), 'PAC 已编译')}><Braces size={14} />编译</Button></div><pre>{pacScript || 'function FindProxyForURL(url, host) { … }'}</pre></section><section><div><h2>配置交换</h2><div><Button variant="ghost" onClick={() => void run(async () => setConfigurationText(JSON.stringify(await request('proxy.config.export'), null, 2)), '代理配置已导出到编辑区')}><Download size={14} />导出</Button><Button variant="ghost" disabled={!configurationText.trim()} onClick={() => void importConfiguration()}><Upload size={14} />导入</Button></div></div><textarea value={configurationText} onChange={(event) => setConfigurationText(event.target.value)} placeholder="代理配置 JSON" /></section><section className="proxy-stats"><div><h2>规则命中</h2><Button variant="ghost" disabled={stats.length === 0} onClick={() => void run(async () => { await request('proxy.rules.stats.clear'); setStats([]); }, '命中统计已清空')}><Trash2 size={14} />清空</Button></div>{stats.length === 0 ? <span>暂无命中</span> : stats.map((item) => <p key={item.ruleId}><strong>{state.proxyRules.find((rule) => rule.id === item.ruleId)?.name || item.ruleId}</strong><span>{item.hits}</span></p>)}</section></div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function CookieEditor({ tab, run, busy }: { tab?: ActiveTabInfo; run: (task: () => Promise<void>, success?: string) => Promise<void>; busy: boolean }) {
|
||||
const [url, setUrl] = useState(tab?.url || '');
|
||||
@@ -439,9 +374,7 @@ function CookieEditor({ tab, run, busy }: { tab?: ActiveTabInfo; run: (task: ()
|
||||
const [filter, setFilter] = useState<'all' | 'session' | 'persistent' | 'httpOnly' | 'partitioned'>('all');
|
||||
const [sort, setSort] = useState<'name' | 'domain' | 'expires' | 'size'>('name');
|
||||
const [group, setGroup] = useState<'none' | 'domain' | 'path'>('domain');
|
||||
const [revealed, setRevealed] = useState<Set<string>>(new Set());
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [editorValueVisible, setEditorValueVisible] = useState(false);
|
||||
const [transferFormat, setTransferFormat] = useState<CookieTransferFormat>('json');
|
||||
const [includeExportValues, setIncludeExportValues] = useState(false);
|
||||
const [importText, setImportText] = useState('');
|
||||
@@ -449,14 +382,12 @@ function CookieEditor({ tab, run, busy }: { tab?: ActiveTabInfo; run: (task: ()
|
||||
const [draft, setDraft] = useState<Omit<CookieInput, 'url'>>({
|
||||
name: '', value: '', path: '/', secure: url.startsWith('https:'), httpOnly: false, sameSite: 'unspecified',
|
||||
});
|
||||
const keyOf = (cookie: BrowserCookie) => `${cookie.storeId}:${cookie.partitionKey?.topLevelSite || ''}:${cookie.domain}:${cookie.path}:${cookie.name}`;
|
||||
const keyOf = cookieKey;
|
||||
const reload = () => run(async () => {
|
||||
setCookies(await request('cookie.list', { url }));
|
||||
setSelected(new Set());
|
||||
setRevealed(new Set());
|
||||
});
|
||||
const editCookie = (cookie: BrowserCookie) => {
|
||||
setEditorValueVisible(false);
|
||||
setDraft({
|
||||
name: cookie.name, value: cookie.value, domain: cookie.hostOnly ? undefined : cookie.domain,
|
||||
path: cookie.path, secure: cookie.secure, httpOnly: cookie.httpOnly,
|
||||
@@ -464,11 +395,6 @@ function CookieEditor({ tab, run, busy }: { tab?: ActiveTabInfo; run: (task: ()
|
||||
storeId: cookie.storeId, firstPartyDomain: cookie.firstPartyDomain, partitionKey: cookie.partitionKey,
|
||||
});
|
||||
};
|
||||
const cookieUrl = (cookie: BrowserCookie) => {
|
||||
const domain = cookie.domain.replace(/^\./, '');
|
||||
const path = cookie.path.startsWith('/') ? cookie.path : `/${cookie.path}`;
|
||||
return `${cookie.secure ? 'https' : 'http'}://${domain}${path}`;
|
||||
};
|
||||
const visibleCookies = cookies.filter((cookie) => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
const queryMatch = !needle || [cookie.name, cookie.domain, cookie.path].some((value) => value.toLowerCase().includes(needle));
|
||||
@@ -486,10 +412,7 @@ function CookieEditor({ tab, run, busy }: { tab?: ActiveTabInfo; run: (task: ()
|
||||
const key = group === 'domain' ? cookie.domain : group === 'path' ? cookie.path : '全部 Cookie';
|
||||
groupedCookies.set(key, [...(groupedCookies.get(key) || []), cookie]);
|
||||
}
|
||||
const removeInputs = (items: BrowserCookie[]) => items.map((cookie) => ({
|
||||
url: cookieUrl(cookie), name: cookie.name, storeId: cookie.storeId,
|
||||
firstPartyDomain: cookie.firstPartyDomain, partitionKey: cookie.partitionKey,
|
||||
}));
|
||||
const removeInputs = (items: BrowserCookie[]) => items.map(cookieRemovalInput);
|
||||
const downloadExport = async () => {
|
||||
const text = await request('cookie.export', { url, format: transferFormat, includeValues: includeExportValues });
|
||||
const blobUrl = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' }));
|
||||
@@ -506,20 +429,62 @@ function CookieEditor({ tab, run, busy }: { tab?: ActiveTabInfo; run: (task: ()
|
||||
<div className="cookie-toolbar"><div className="network-search"><Search size={14} /><input aria-label="搜索 Cookie" placeholder="搜索名称、Domain 或 Path" value={query} onChange={(event) => setQuery(event.target.value)} /></div><select aria-label="Cookie 筛选" value={filter} onChange={(event) => setFilter(event.target.value as typeof filter)}><option value="all">全部</option><option value="session">Session</option><option value="persistent">持久</option><option value="httpOnly">HttpOnly</option><option value="partitioned">Partitioned</option></select><select aria-label="Cookie 排序" value={sort} onChange={(event) => setSort(event.target.value as typeof sort)}><option value="name">按名称</option><option value="domain">按 Domain</option><option value="expires">按过期时间</option><option value="size">按值大小</option></select><select aria-label="Cookie 分组" value={group} onChange={(event) => setGroup(event.target.value as typeof group)}><option value="domain">Domain 分组</option><option value="path">Path 分组</option><option value="none">不分组</option></select><Button variant="danger" disabled={busy || selected.size === 0} onClick={() => void run(async () => { const result = await request('cookie.removeMany', { cookies: removeInputs(cookies.filter((cookie) => selected.has(keyOf(cookie)))) }); setTransferStatus(`删除 ${result.removed},失败 ${result.failed}`); setCookies(await request('cookie.list', { url })); setSelected(new Set()); }, '已执行批量删除')}><Trash2 size={14} />删除 {selected.size || ''}</Button></div>
|
||||
<div className="cookie-layout"><div className="cookie-table"><div className="table-head cookie-columns"><input aria-label="选择全部可见 Cookie" type="checkbox" checked={visibleCookies.length > 0 && visibleCookies.every((cookie) => selected.has(keyOf(cookie)))} onChange={(event) => setSelected(event.target.checked ? new Set(visibleCookies.map(keyOf)) : new Set())} /><span>名称</span><span>值</span><span>Domain / Path</span><span>属性</span><span /></div>{visibleCookies.length === 0 ? <Empty>没有符合条件的 Cookie。</Empty> : [...groupedCookies].map(([groupName, items]) => <div className="cookie-group" key={groupName}><div className="cookie-group__heading"><strong>{groupName}</strong><span>{items.length}</span></div>{items.map((cookie) => {
|
||||
const cookieKey = keyOf(cookie);
|
||||
const valueVisible = revealed.has(cookieKey);
|
||||
return <div className="table-row cookie-columns" key={cookieKey}><input aria-label={`选择 ${cookie.name}`} type="checkbox" checked={selected.has(cookieKey)} onChange={(event) => setSelected((current) => { const next = new Set(current); if (event.target.checked) next.add(cookieKey); else next.delete(cookieKey); return next; })} /><button className="cookie-name-button" title="编辑 Cookie" onClick={() => editCookie(cookie)}><strong>{cookie.name}</strong></button><button className="cookie-value-button" title={valueVisible ? '隐藏 Cookie 值' : '显示 Cookie 值'} onClick={() => setRevealed((current) => { const next = new Set(current); if (next.has(cookieKey)) next.delete(cookieKey); else next.add(cookieKey); return next; })}><code>{valueVisible ? cookie.value : `[hidden ${cookie.value.length}]`}</code>{valueVisible ? <EyeOff size={13} /> : <Eye size={13} />}</button><span><small>{cookie.domain}</small><small>{cookie.path}</small></span><span className="tag-list">{cookie.httpOnly && <i>HttpOnly</i>}{cookie.secure && <i>Secure</i>}{cookie.partitionKey && <i>Partitioned</i>}{cookie.sameSite && <i>{cookie.sameSite}</i>}{cookie.priority && <i>{cookie.priority}</i>}{cookie.sameParty && <i>SameParty</i>}</span><button className="icon-button danger" title="删除 Cookie" onClick={() => void run(async () => { await request('cookie.remove', removeInputs([cookie])[0]); setCookies(await request('cookie.list', { url })); }, 'Cookie 已删除')}><Trash2 size={15} /></button></div>;
|
||||
return <div className="table-row cookie-columns" key={cookieKey}><input aria-label={`选择 ${cookie.name}`} type="checkbox" checked={selected.has(cookieKey)} onChange={(event) => setSelected((current) => { const next = new Set(current); if (event.target.checked) next.add(cookieKey); else next.delete(cookieKey); return next; })} /><button className="cookie-name-button" title="编辑 Cookie" onClick={() => editCookie(cookie)}><strong>{cookie.name}</strong></button><code className="cookie-value" title={cookie.value}>{cookie.value}</code><span><small>{cookie.domain}</small><small>{cookie.path}</small></span><span className="tag-list">{cookie.httpOnly && <i>HttpOnly</i>}{cookie.secure && <i>Secure</i>}{cookie.partitionKey && <i>Partitioned</i>}{cookie.sameSite && <i>{cookie.sameSite}</i>}{cookie.priority && <i>{cookie.priority}</i>}{cookie.sameParty && <i>SameParty</i>}</span><button className="icon-button danger" title="删除 Cookie" onClick={() => void run(async () => { await request('cookie.remove', removeInputs([cookie])[0]); setCookies(await request('cookie.list', { url })); }, 'Cookie 已删除')}><Trash2 size={15} /></button></div>;
|
||||
})}</div>)}</div>
|
||||
<div className="rule-editor cookie-editor-pane"><h2>写入 Cookie</h2><Field label="名称"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></Field><Field label="值"><div className={`secret-field ${editorValueVisible ? '' : 'masked'}`}><textarea rows={4} value={draft.value} onChange={(event) => setDraft({ ...draft, value: event.target.value })} /><Button size="icon" variant="ghost" title={editorValueVisible ? '隐藏值' : '显示值'} aria-label={editorValueVisible ? '隐藏值' : '显示值'} onClick={() => setEditorValueVisible(!editorValueVisible)}>{editorValueVisible ? <EyeOff size={14} /> : <Eye size={14} />}</Button></div></Field><Field label="Domain" hint="留空创建 HostOnly Cookie"><input value={draft.domain || ''} onChange={(event) => setDraft({ ...draft, domain: event.target.value || undefined })} /></Field><Field label="Path"><input value={draft.path} onChange={(event) => setDraft({ ...draft, path: event.target.value })} /></Field><Field label="过期时间"><input type="datetime-local" value={draft.expirationDate ? new Date(draft.expirationDate * 1_000).toISOString().slice(0, 16) : ''} onChange={(event) => setDraft({ ...draft, expirationDate: event.target.value ? new Date(event.target.value).getTime() / 1_000 : undefined })} /></Field><Field label="SameSite"><select value={draft.sameSite} onChange={(event) => setDraft({ ...draft, sameSite: event.target.value as CookieInput['sameSite'] })}><option value="unspecified">Unspecified</option><option value="lax">Lax</option><option value="strict">Strict</option><option value="no_restriction">None</option></select></Field><Field label="Partition top-level site"><input placeholder="https://top.example" value={draft.partitionKey?.topLevelSite || ''} onChange={(event) => setDraft({ ...draft, partitionKey: event.target.value ? { ...draft.partitionKey, topLevelSite: event.target.value } : undefined })} /></Field><label className="check-row"><input type="checkbox" checked={draft.secure} onChange={(event) => setDraft({ ...draft, secure: event.target.checked })} />Secure</label><label className="check-row"><input type="checkbox" checked={draft.httpOnly} onChange={(event) => setDraft({ ...draft, httpOnly: event.target.checked })} />HttpOnly</label><label className="check-row"><input type="checkbox" disabled={!draft.partitionKey} checked={draft.partitionKey?.hasCrossSiteAncestor || false} onChange={(event) => setDraft({ ...draft, partitionKey: { ...draft.partitionKey, hasCrossSiteAncestor: event.target.checked } })} />Cross-site ancestor</label><button className="primary-button" disabled={busy || !url || !draft.name} onClick={() => void run(async () => { await request('cookie.set', { url, ...draft }); setCookies(await request('cookie.list', { url })); }, 'Cookie 已写入')}><Save size={16} />保存 Cookie</button><div className="cookie-transfer"><h2>导入 / 导出</h2><div><select value={transferFormat} onChange={(event) => setTransferFormat(event.target.value as CookieTransferFormat)}><option value="json">JSON</option><option value="netscape">Netscape</option><option value="set-cookie">Set-Cookie</option></select><label className="check-row"><input type="checkbox" checked={includeExportValues} onChange={(event) => setIncludeExportValues(event.target.checked)} />导出原始值</label></div><textarea rows={6} value={importText} onChange={(event) => setImportText(event.target.value)} placeholder="粘贴 Cookie 数据" /><div className="editor-actions"><Button variant="primary" disabled={busy || !importText.trim()} onClick={() => void run(async () => { const result = await request('cookie.import', { url, format: transferFormat, text: importText }); setTransferStatus(`导入 ${result.imported},失败 ${result.failed}${result.warnings.length ? `;${result.warnings.join(';')}` : ''}`); setCookies(await request('cookie.list', { url })); }, 'Cookie 导入完成')}><Upload size={14} />导入</Button><Button variant="ghost" disabled={busy || cookies.length === 0} onClick={() => void run(downloadExport, includeExportValues ? 'Cookie 已导出(包含值)' : 'Cookie 已脱敏导出')}><Download size={14} />导出</Button></div>{transferStatus && <p className="transfer-status">{transferStatus}</p>}</div></div>
|
||||
<div className="rule-editor cookie-editor-pane"><h2>写入 Cookie</h2><Field label="名称"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></Field><Field label="值"><textarea rows={4} value={draft.value} onChange={(event) => setDraft({ ...draft, value: event.target.value })} /></Field><Field label="Domain" hint="留空创建 HostOnly Cookie"><input value={draft.domain || ''} onChange={(event) => setDraft({ ...draft, domain: event.target.value || undefined })} /></Field><Field label="Path"><input value={draft.path} onChange={(event) => setDraft({ ...draft, path: event.target.value })} /></Field><Field label="过期时间"><input type="datetime-local" value={draft.expirationDate ? new Date(draft.expirationDate * 1_000).toISOString().slice(0, 16) : ''} onChange={(event) => setDraft({ ...draft, expirationDate: event.target.value ? new Date(event.target.value).getTime() / 1_000 : undefined })} /></Field><Field label="SameSite"><select value={draft.sameSite} onChange={(event) => setDraft({ ...draft, sameSite: event.target.value as CookieInput['sameSite'] })}><option value="unspecified">Unspecified</option><option value="lax">Lax</option><option value="strict">Strict</option><option value="no_restriction">None</option></select></Field><Field label="Partition top-level site"><input placeholder="https://top.example" value={draft.partitionKey?.topLevelSite || ''} onChange={(event) => setDraft({ ...draft, partitionKey: event.target.value ? { ...draft.partitionKey, topLevelSite: event.target.value } : undefined })} /></Field><label className="check-row"><input type="checkbox" checked={draft.secure} onChange={(event) => setDraft({ ...draft, secure: event.target.checked })} />Secure</label><label className="check-row"><input type="checkbox" checked={draft.httpOnly} onChange={(event) => setDraft({ ...draft, httpOnly: event.target.checked })} />HttpOnly</label><label className="check-row"><input type="checkbox" disabled={!draft.partitionKey} checked={draft.partitionKey?.hasCrossSiteAncestor || false} onChange={(event) => setDraft({ ...draft, partitionKey: { ...draft.partitionKey, hasCrossSiteAncestor: event.target.checked } })} />Cross-site ancestor</label><button className="primary-button" disabled={busy || !url || !draft.name} onClick={() => void run(async () => { await request('cookie.set', { url, ...draft }); setCookies(await request('cookie.list', { url })); }, 'Cookie 已写入')}><Save size={16} />保存 Cookie</button><div className="cookie-transfer"><h2>导入 / 导出</h2><div><select value={transferFormat} onChange={(event) => setTransferFormat(event.target.value as CookieTransferFormat)}><option value="json">JSON</option><option value="netscape">Netscape</option><option value="set-cookie">Set-Cookie</option></select><label className="check-row"><input type="checkbox" checked={includeExportValues} onChange={(event) => setIncludeExportValues(event.target.checked)} />导出原始值</label></div><textarea rows={6} value={importText} onChange={(event) => setImportText(event.target.value)} placeholder="粘贴 Cookie 数据" /><div className="editor-actions"><Button variant="primary" disabled={busy || !importText.trim()} onClick={() => void run(async () => { const result = await request('cookie.import', { url, format: transferFormat, text: importText }); setTransferStatus(`导入 ${result.imported},失败 ${result.failed}${result.warnings.length ? `;${result.warnings.join(';')}` : ''}`); setCookies(await request('cookie.list', { url })); }, 'Cookie 导入完成')}><Upload size={14} />导入</Button><Button variant="ghost" disabled={busy || cookies.length === 0} onClick={() => void run(downloadExport, includeExportValues ? 'Cookie 已导出(包含值)' : 'Cookie 已脱敏导出')}><Download size={14} />导出</Button></div>{transferStatus && <p className="transfer-status">{transferStatus}</p>}</div></div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function UserAgents({ state, setState, run, busy }: { state: ExtensionState; setState: (state: ExtensionState) => void; run: (task: () => Promise<void>, success?: string) => Promise<void>; busy: boolean }) {
|
||||
const [draft, setDraft] = useState<UserAgentRule>({ id: uuidv7(), name: UA_PRESETS[0][0], enabled: true, userAgent: UA_PRESETS[0][1], domains: [] });
|
||||
return <div className="section-view">
|
||||
<div className="page-heading"><div><h1>User-Agent 请求头</h1><p>通过 Manifest V3 动态规则修改真实网络请求头,可按域名限定;不伪装页面 JS 设备指纹。</p></div></div>
|
||||
<div className="rule-layout"><div className="rule-table"><div className="table-head"><span>名称</span><span>User-Agent</span><span>域名</span><span>状态</span><span /></div>{state.userAgentRules.length === 0 ? <Empty>还没有 User-Agent 规则。</Empty> : state.userAgentRules.map((rule) => <div className="table-row" key={rule.id}><span><strong>{rule.name}</strong></span><code title={rule.userAgent}>{rule.userAgent}</code><span>{rule.domains.length ? rule.domains.join(', ') : '所有站点'}</span><span className={rule.enabled ? 'status-good' : 'status-muted'}>{rule.enabled ? '启用' : '停用'}</span><button className="icon-button danger" title="删除 UA 规则" onClick={() => void run(async () => setState(await request('ua.delete', { id: rule.id })), 'UA 规则已删除')}><Trash2 size={15} /></button></div>)}</div>
|
||||
<div className="rule-editor"><h2>新建 UA 规则</h2><Field label="预设"><select onChange={(event) => { const preset = UA_PRESETS[Number(event.target.value)]; setDraft({ ...draft, name: preset[0], userAgent: preset[1] }); }}>{UA_PRESETS.map((preset, index) => <option key={preset[0]} value={index}>{preset[0]}</option>)}</select></Field><Field label="名称"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></Field><Field label="User-Agent"><textarea rows={5} value={draft.userAgent} onChange={(event) => setDraft({ ...draft, userAgent: event.target.value })} /></Field><Field label="限定域名" hint="每行一个,留空表示所有站点"><textarea rows={4} value={draft.domains.join('\n')} onChange={(event) => setDraft({ ...draft, domains: event.target.value.split('\n').map((item) => item.trim()).filter(Boolean) })} /></Field><button className="primary-button" disabled={busy || !draft.name || !draft.userAgent} onClick={() => void run(async () => { setState(await request('ua.save', draft)); setDraft({ ...draft, id: uuidv7() }); }, 'UA 规则已生效')}><Save size={16} />保存并应用</button></div>
|
||||
function UserAgents({ state, setState, tab, run, busy }: { state: ExtensionState; setState: (state: ExtensionState) => void; tab?: ActiveTabInfo; run: (task: () => Promise<void>, success?: string) => Promise<void>; busy: boolean }) {
|
||||
const [profiles, setProfiles] = useState<UserAgentProfile[]>([]);
|
||||
const [selectedProfileId, setSelectedProfileId] = useState('chrome-windows');
|
||||
const [draft, setDraft] = useState<UserAgentProfileInput>({ name: '', userAgent: '' });
|
||||
const url = tab?.url?.startsWith('http') ? tab.url : '';
|
||||
let hostname = '';
|
||||
try { hostname = url ? new URL(url).hostname : ''; } catch { hostname = ''; }
|
||||
const currentAssignment = state.userAgentAssignments.find((assignment) => assignment.hostname === hostname);
|
||||
const profileMap = new Map(profiles.map((profile) => [profile.id, profile]));
|
||||
const selectedProfile = profileMap.get(selectedProfileId);
|
||||
const effectiveProfile = currentAssignment ? profileMap.get(currentAssignment.profileId) : undefined;
|
||||
|
||||
const loadProfiles = useCallback(async () => {
|
||||
const next = await request('ua.catalog');
|
||||
setProfiles(next);
|
||||
const current = state.userAgentAssignments.find((assignment) => assignment.hostname === hostname);
|
||||
if (current && next.some((profile) => profile.id === current.profileId)) setSelectedProfileId(current.profileId);
|
||||
}, [hostname, state.userAgentAssignments]);
|
||||
useEffect(() => { void loadProfiles(); }, [loadProfiles, state.customUserAgentProfiles]);
|
||||
|
||||
const applyAndReload = () => run(async () => {
|
||||
if (!tab || !url || !selectedProfile) throw new Error('请选择可访问的目标页面和 User-Agent 预设');
|
||||
setState(await request('ua.site.apply', { url, profileId: selectedProfile.id }));
|
||||
await browser.tabs.reload(tab.id);
|
||||
}, `${selectedProfile?.name || 'User-Agent'} 已应用并刷新页面`);
|
||||
const resetAndReload = () => run(async () => {
|
||||
if (!tab || !url) throw new Error('请选择可访问的目标页面');
|
||||
setState(await request('ua.site.reset', { url }));
|
||||
await browser.tabs.reload(tab.id);
|
||||
}, '已恢复浏览器默认 User-Agent 并刷新页面');
|
||||
const saveProfile = () => run(async () => {
|
||||
const saved = await request('ua.profile.save', draft);
|
||||
const next = await request('ua.catalog');
|
||||
setProfiles(next);
|
||||
setSelectedProfileId(saved.id);
|
||||
setDraft({ name: '', userAgent: '' });
|
||||
}, '自定义 User-Agent 预设已保存');
|
||||
|
||||
return <div className="section-view ua-view">
|
||||
<div className="page-heading"><div><span className="page-eyebrow">常用工具</span><h1>User-Agent 快速切换</h1><p>为单个 hostname 修改真实网络请求头;不伪装 Navigator、Client Hints、屏幕或 TLS 指纹。</p></div></div>
|
||||
<section className="ua-current-site">
|
||||
<div><span>当前目标</span><strong>{hostname || '当前标签页不可配置'}</strong><small>{effectiveProfile ? `正在使用 ${effectiveProfile.name}` : '使用浏览器默认 User-Agent'}</small></div>
|
||||
<select aria-label="当前站点 User-Agent" disabled={!hostname || busy} value={selectedProfileId} onChange={(event) => setSelectedProfileId(event.target.value)}>{profiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}{profile.builtin ? '' : ' · 自定义'}</option>)}</select>
|
||||
<div className="editor-actions"><Button variant="ghost" disabled={!currentAssignment || busy} onClick={() => void resetAndReload()}>恢复默认</Button><Button variant="primary" disabled={!hostname || !selectedProfile || busy} onClick={() => void applyAndReload()}><RefreshCw size={14} />应用并刷新</Button></div>
|
||||
</section>
|
||||
<div className="ua-management">
|
||||
<section className="ua-assignments"><div className="context-section-heading"><div><h2>站点绑定</h2><span>每个 hostname 只保留一个生效预设</span></div></div>{state.userAgentAssignments.length === 0 ? <Empty>还没有站点 User-Agent 绑定。</Empty> : <div className="ua-assignment-list">{[...state.userAgentAssignments].sort((left, right) => left.hostname.localeCompare(right.hostname)).map((assignment) => { const profile = profileMap.get(assignment.profileId); return <div key={assignment.id}><span><strong>{assignment.hostname}</strong><small>{profile?.name || '预设已删除'}</small></span><code title={profile?.userAgent}>{profile?.userAgent || assignment.profileId}</code><Button size="icon" variant="ghost" title="恢复该站点默认 UA" aria-label={`移除 ${assignment.hostname} 的 UA 绑定`} onClick={() => void run(async () => setState(await request('ua.site.reset', { url: `https://${assignment.hostname}/` })), '站点 UA 绑定已移除')}><Trash2 size={14} /></Button></div>; })}</div>}
|
||||
</section>
|
||||
<aside className="ua-profile-editor"><div className="context-section-heading"><div><h2>{draft.id ? '编辑自定义预设' : '自定义预设'}</h2><span>保存后可在 Popup 和当前站点中复用</span></div></div><Field label="名称"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} placeholder="例如 API Client" /></Field><Field label="User-Agent"><textarea rows={5} value={draft.userAgent} onChange={(event) => setDraft({ ...draft, userAgent: event.target.value })} placeholder="Custom-Agent/1.0" /></Field><div className="editor-actions">{draft.id && <Button variant="ghost" onClick={() => setDraft({ name: '', userAgent: '' })}>取消编辑</Button>}<Button variant="primary" disabled={busy || !draft.name.trim() || !draft.userAgent.trim()} onClick={() => void saveProfile()}><Save size={14} />保存预设</Button></div><div className="custom-ua-list">{profiles.filter((profile) => !profile.builtin).map((profile) => <div key={profile.id}><button onClick={() => setDraft({ id: profile.id, name: profile.name, userAgent: profile.userAgent })}><strong>{profile.name}</strong><small>{profile.userAgent}</small></button><Button size="icon" variant="ghost" title="删除自定义预设" aria-label={`删除 ${profile.name}`} onClick={() => void run(async () => { setState(await request('ua.profile.delete', { id: profile.id })); await loadProfiles(); }, '自定义 UA 预设已删除')}><Trash2 size={14} /></Button></div>)}</div></aside>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -545,11 +510,6 @@ function NetworkActivity({ tab, bridge, run, busy }: { tab?: ActiveTabInfo; brid
|
||||
const [captureHeaders, setCaptureHeaders] = useState(false);
|
||||
const [captureBody, setCaptureBody] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [observationStatus, setObservationStatus] = useState<PageObservationStatus>();
|
||||
const [observations, setObservations] = useState<PageObservationRecord[]>([]);
|
||||
const [selectedObservationId, setSelectedObservationId] = useState('');
|
||||
const [captureObservationValues, setCaptureObservationValues] = useState(false);
|
||||
const [observationError, setObservationError] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!tab) return;
|
||||
@@ -581,30 +541,6 @@ function NetworkActivity({ tab, bridge, run, busy }: { tab?: ActiveTabInfo; brid
|
||||
return () => browser.runtime.onMessage.removeListener(listener);
|
||||
}, [load, tab?.id]);
|
||||
|
||||
const loadObservations = useCallback(async () => {
|
||||
if (!tab) return;
|
||||
try {
|
||||
setObservationError('');
|
||||
const nextStatus = await request('observation.status', { tabId: tab.id });
|
||||
setObservationStatus(nextStatus);
|
||||
if (nextStatus.options) setCaptureObservationValues(nextStatus.options.captureValues);
|
||||
const nextRecords = nextStatus.startedAt
|
||||
? await request('observation.list', { ...nextStatus.target, limit: 200 })
|
||||
: [];
|
||||
setObservations(nextRecords);
|
||||
setSelectedObservationId((current) => nextRecords.some((item) => item.id === current) ? current : nextRecords.at(-1)?.id || '');
|
||||
} catch (error) {
|
||||
setObservationError(errorMessage(error));
|
||||
}
|
||||
}, [tab]);
|
||||
|
||||
useEffect(() => { void loadObservations(); }, [loadObservations]);
|
||||
useEffect(() => {
|
||||
if (!observationStatus?.active) return undefined;
|
||||
const timer = window.setInterval(() => void loadObservations(), 1_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [loadObservations, observationStatus?.active]);
|
||||
|
||||
const selected = records.find((record) => record.id === selectedId);
|
||||
useEffect(() => {
|
||||
setExported(undefined);
|
||||
@@ -626,8 +562,6 @@ function NetworkActivity({ tab, bridge, run, busy }: { tab?: ActiveTabInfo; brid
|
||||
const canGeneratePoc = bridge.state === 'connected' && Boolean(bridge.capabilities?.includes('yakit.poc.generate'));
|
||||
const canPrepareAnalysis = bridge.state === 'connected' && Boolean(bridge.capabilities?.includes('yakit.browser_request.prepare_analysis'));
|
||||
const captureTarget = status?.active ? status.target : tab ? { tabId: tab.id } : undefined;
|
||||
const observationTarget = observationStatus?.startedAt ? observationStatus.target : tab ? { tabId: tab.id } : undefined;
|
||||
const selectedObservation = observations.find((item) => item.id === selectedObservationId);
|
||||
|
||||
const start = () => run(async () => {
|
||||
if (!tab) throw new Error('请选择目标标签页');
|
||||
@@ -639,16 +573,6 @@ function NetworkActivity({ tab, bridge, run, busy }: { tab?: ActiveTabInfo; brid
|
||||
setSelectedId('');
|
||||
}, captureHeaders || captureBody ? '网络捕获已开始,敏感字段仅保存在本次浏览器会话' : '网络元数据捕获已开始');
|
||||
|
||||
const startObservation = () => run(async () => {
|
||||
if (!tab) throw new Error('请选择目标标签页');
|
||||
const next = await request('observation.start', {
|
||||
tabId: tab.id, captureValues: captureObservationValues, maxEntries: 100, maxValueBytes: 2_048,
|
||||
});
|
||||
setObservationStatus(next);
|
||||
setObservations([]);
|
||||
setSelectedObservationId('');
|
||||
}, captureObservationValues ? '页面观测已开始,值预览将在授权到期或停止时销毁' : '页面元数据观测已开始');
|
||||
|
||||
return <div className="section-view network-view">
|
||||
<div className="page-heading"><div><h1>网络活动</h1><p>HTTP 请求、表单导航、实时通信与前端加密调用。</p></div><div className="network-heading-actions">
|
||||
<span className={`capture-state ${status?.active ? 'active' : ''}`}><i />{status?.active ? `${status.count} 条请求` : '未捕获'}</span>
|
||||
@@ -690,39 +614,7 @@ function NetworkActivity({ tab, bridge, run, busy }: { tab?: ActiveTabInfo; brid
|
||||
</aside>
|
||||
</div>}
|
||||
|
||||
<section className="observation-section">
|
||||
<div className="observation-heading">
|
||||
<div><span>页面行为观测</span><h2>加密与实时通信</h2></div>
|
||||
<div className="network-heading-actions">
|
||||
<span className={`capture-state ${observationStatus?.active ? 'active' : ''}`}><i />{observationStatus?.active ? `${observationStatus.count} 条事件` : observationStatus?.startedAt ? '已停止' : '未观测'}</span>
|
||||
{observationStatus?.active
|
||||
? <Button variant="ghost" disabled={busy || !observationTarget} onClick={() => void run(async () => { setObservationStatus(await request('observation.stop', observationTarget!)); await loadObservations(); }, '页面观测已停止')}><Square size={14} />停止</Button>
|
||||
: <Button variant="primary" disabled={busy || !tab?.url?.startsWith('http')} onClick={() => void startObservation()}><Play size={14} />开始观测</Button>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="network-control-bar observation-controls">
|
||||
<label><Switch checked={captureObservationValues} disabled={observationStatus?.active || busy} onCheckedChange={setCaptureObservationValues} /><span><strong>短时值预览</strong><small>关闭时仅记录算法、大小、方向和调用来源</small></span></label>
|
||||
<span className="observation-kinds">Fetch / XHR / Form · WebSocket · WebCrypto · CryptoJS</span>
|
||||
<Button size="icon" variant="ghost" title="刷新页面观测" aria-label="刷新页面观测" onClick={() => void loadObservations()}><RefreshCw size={15} /></Button>
|
||||
<Button size="icon" variant="ghost" title="清空页面观测" aria-label="清空页面观测" disabled={!observationTarget || observations.length === 0 || busy} onClick={() => void run(async () => { setObservationStatus(await request('observation.clear', observationTarget!)); setObservations([]); setSelectedObservationId(''); }, '页面观测记录已清空')}><Trash2 size={15} /></Button>
|
||||
</div>
|
||||
{observationError ? <div className="network-error"><AlertTriangle size={15} />{observationError}<Button size="sm" variant="ghost" onClick={() => void loadObservations()}>重试</Button></div> : <div className="observation-layout">
|
||||
<div className="observation-timeline">
|
||||
<div className="observation-table-head"><span>类型</span><span>操作</span><span>目标 / 算法</span><span>数据</span><span>时间</span></div>
|
||||
{observations.length === 0 ? <Empty>{observationStatus?.active ? '等待页面行为事件。' : '当前文档没有观测记录。'}</Empty> : observations.map((item) => <button key={item.id} className={`observation-row ${item.id === selectedObservationId ? 'selected' : ''}`} onClick={() => setSelectedObservationId(item.id)}>
|
||||
<strong>{item.kind}</strong><span>{item.direction || item.operation}</span><span className="observation-target"><strong>{item.algorithm || item.url || item.operation}</strong><small>{item.scriptUrl || item.dataType || '页面主世界'}</small></span><span>{item.byteLength === undefined ? '—' : `${item.byteLength} B`}</span><time>{new Date(item.timestamp).toLocaleTimeString()}</time>
|
||||
</button>)}
|
||||
</div>
|
||||
<aside className="network-inspector observation-inspector">
|
||||
{!selectedObservation ? <Empty>选择一条事件查看详情。</Empty> : <>
|
||||
<div className="network-inspector__heading"><div><span>{selectedObservation.kind}</span><strong>{selectedObservation.algorithm || selectedObservation.operation}</strong><small title={selectedObservation.url || selectedObservation.scriptUrl}>{selectedObservation.url || selectedObservation.scriptUrl || '页面主世界'}</small></div><span className={selectedObservation.error ? 'status-error' : 'status-good'}>{selectedObservation.error ? 'ERROR' : `#${selectedObservation.sequence}`}</span></div>
|
||||
<dl className="network-meta"><div><dt>方向</dt><dd>{selectedObservation.direction || '调用'}</dd></div><div><dt>数据类型</dt><dd>{selectedObservation.dataType || '未知'}</dd></div><div><dt>输入大小</dt><dd>{selectedObservation.byteLength === undefined ? '未知' : `${selectedObservation.byteLength} B`}</dd></div><div><dt>输出大小</dt><dd>{selectedObservation.resultByteLength === undefined ? '未知' : `${selectedObservation.resultByteLength} B`}</dd></div></dl>
|
||||
{(selectedObservation.inputPreview || selectedObservation.outputPreview) && <div className="observation-values"><strong>授权值预览</strong>{selectedObservation.inputPreview && <pre>{selectedObservation.inputPreview}</pre>}{selectedObservation.outputPreview && <pre>{selectedObservation.outputPreview}</pre>}</div>}
|
||||
<div className="observation-stack"><strong>调用来源</strong><pre>{selectedObservation.stack || selectedObservation.scriptUrl || '未提供调用栈'}</pre></div>
|
||||
</>}
|
||||
</aside>
|
||||
</div>}
|
||||
</section>
|
||||
<RecordingWorkspace tab={tab} busy={busy} run={run} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -932,7 +824,7 @@ function EngineSettings({ state, setState, bridge, setBridge, tabs, run, busy }:
|
||||
<label className="toggle-row"><span><strong>全屏自动收起</strong><small>进入全屏、演示或视频场景时关闭展开内容</small></span><Switch checked={panelDraft.autoCollapseFullscreen} onCheckedChange={(autoCollapseFullscreen) => setPanelDraft({ ...panelDraft, autoCollapseFullscreen })} /></label>
|
||||
<div className="editor-actions"><Button disabled={busy} onClick={() => void savePanel()}><Save size={16} />保存面板策略</Button></div>
|
||||
</section>
|
||||
<div className="grant-editor"><h2>浏览器共享会话</h2><p>只把明确勾选的 frame 和能力授权给当前 Agent;子 frame、刷新和跨来源导航不会静默继承授权。</p><div className="tab-picker">{tabs.map((tabItem) => { const frames = framesByTab[tabItem.id] || []; const mainSelected = selectedTargets.includes(`${tabItem.id}:0`); return <div className="tab-picker-group" key={tabItem.id}><label><input type="checkbox" checked={mainSelected} onChange={(event) => toggleTab(tabItem.id, event.target.checked)} /><span><strong>{tabItem.title}</strong><small>{tabItem.url}</small></span></label>{mainSelected && frames.filter((frame) => !frame.isTop).map((frame) => <label className="frame-target" key={frame.frameId}><input type="checkbox" disabled={!frame.accessible || !frame.origin} checked={selectedTargets.includes(`${tabItem.id}:${frame.frameId}`)} onChange={(event) => toggleTarget(`${tabItem.id}:${frame.frameId}`, event.target.checked)} /><span><strong>{frame.title || frame.name || `Frame ${frame.frameId}`}</strong><small>#{frame.frameId} · {frame.sameOrigin ? '同源' : '跨源'} · {frame.origin || frame.url}</small></span></label>)}</div>; })}</div><div className="grant-options"><Field label="权限预设"><select value={grantLevel} onChange={(event) => setGrantLevel(event.target.value as 'read' | 'control')}><option value="read">只读:页面、Storage、Cookie</option><option value="control">控制:节点操作、函数调用、表达式 Eval、接管、代理</option></select></Field><Field label="有效期"><select value={durationMinutes} onChange={(event) => setDurationMinutes(Number(event.target.value))}><option value="15">15 分钟</option><option value="30">30 分钟</option><option value="60">1 小时</option><option value="240">4 小时</option></select></Field></div>{grantLevel === 'control' && <label className="toggle-row grant-risk-toggle"><span><strong>允许程序 Eval</strong><small>独立高风险 scope,可执行多条语句并产生页面副作用</small></span><Switch disabled={policy.policy.allowProgramEval === false} checked={allowProgramEval && policy.policy.allowProgramEval !== false} onCheckedChange={setAllowProgramEval} /></label>}<div className="grant-scope-list">{selectedGrantScopes.filter((scope) => policy.policy.allowProgramEval !== false || scope !== 'browser.page.eval.program').map((scope) => <span key={scope}>{CAPABILITY_LABELS[scope]}</span>)}</div><div className="editor-actions"><button className="primary-button" disabled={busy || selectedTargets.length === 0} onClick={() => void run(async () => setState(await request('grant.create', { targets: selectedTargets.map((key) => { const [tabId, frameId] = key.split(':').map(Number); return { tabId, frameId }; }), scopes: selectedGrantScopes.filter((scope) => policy.policy.allowProgramEval !== false || scope !== 'browser.page.eval.program'), durationMinutes })), '共享会话已创建')}><ShieldCheck size={16} />创建会话</button>{state.activeGrant && <button className="danger-button" onClick={() => void run(async () => setState(await request('grant.revoke')), '共享会话已撤销')}><X size={16} />立即撤销</button>}</div>{state.activeGrant && <div className="grant-status"><strong>{isControlScopeSet(state.activeGrant.scopes) ? '控制会话' : '只读会话'}</strong><span>{state.activeGrant.targets.length} 个 frame · {state.activeGrant.scopes.length} 项能力 · {new Date(state.activeGrant.expiresAt).toLocaleString()} 到期</span></div>}</div></div>
|
||||
<div className="grant-editor"><h2>浏览器共享会话</h2><p>只把明确勾选的 frame 和能力授权给当前 Agent;子 frame、刷新和跨来源导航不会静默继承授权。</p><div className="tab-picker">{tabs.map((tabItem) => { const frames = framesByTab[tabItem.id] || []; const mainSelected = selectedTargets.includes(`${tabItem.id}:0`); return <div className="tab-picker-group" key={tabItem.id}><label><input type="checkbox" checked={mainSelected} onChange={(event) => toggleTab(tabItem.id, event.target.checked)} /><span><strong>{tabItem.title}</strong><small>{tabItem.url}</small></span></label>{mainSelected && frames.filter((frame) => !frame.isTop).map((frame) => <label className="frame-target" key={frame.frameId}><input type="checkbox" disabled={!frame.accessible || !frame.origin} checked={selectedTargets.includes(`${tabItem.id}:${frame.frameId}`)} onChange={(event) => toggleTarget(`${tabItem.id}:${frame.frameId}`, event.target.checked)} /><span><strong>{frame.title || frame.name || `Frame ${frame.frameId}`}</strong><small>#{frame.frameId} · {frame.sameOrigin ? '同源' : '跨源'} · {frame.origin || frame.url}</small></span></label>)}</div>; })}</div><div className="grant-options"><Field label="权限预设"><select value={grantLevel} onChange={(event) => setGrantLevel(event.target.value as 'read' | 'control')}><option value="read">只读:页面、Storage、Cookie</option><option value="control">控制:页面操作、网络敏感字段、深度捕获、代理</option></select></Field><Field label="有效期"><select value={durationMinutes} onChange={(event) => setDurationMinutes(Number(event.target.value))}><option value="15">15 分钟</option><option value="30">30 分钟</option><option value="60">1 小时</option><option value="240">4 小时</option></select></Field></div>{grantLevel === 'control' && <label className="toggle-row grant-risk-toggle"><span><strong>允许程序 Eval</strong><small>独立高风险 scope,可执行多条语句并产生页面副作用</small></span><Switch disabled={policy.policy.allowProgramEval === false} checked={allowProgramEval && policy.policy.allowProgramEval !== false} onCheckedChange={setAllowProgramEval} /></label>}<div className="grant-scope-list">{selectedGrantScopes.filter((scope) => policy.policy.allowProgramEval !== false || scope !== 'browser.page.eval.program').map((scope) => <span key={scope}>{CAPABILITY_LABELS[scope]}</span>)}</div><div className="editor-actions"><button className="primary-button" disabled={busy || selectedTargets.length === 0} onClick={() => void run(async () => setState(await request('grant.create', { targets: selectedTargets.map((key) => { const [tabId, frameId] = key.split(':').map(Number); return { tabId, frameId }; }), scopes: selectedGrantScopes.filter((scope) => policy.policy.allowProgramEval !== false || scope !== 'browser.page.eval.program'), durationMinutes })), '共享会话已创建')}><ShieldCheck size={16} />创建会话</button>{state.activeGrant && <button className="danger-button" onClick={() => void run(async () => setState(await request('grant.revoke')), '共享会话已撤销')}><X size={16} />立即撤销</button>}</div>{state.activeGrant && <div className="grant-status"><strong>{isControlScopeSet(state.activeGrant.scopes) ? '控制会话' : '只读会话'}</strong><span>{state.activeGrant.targets.length} 个 frame · {state.activeGrant.scopes.length} 项能力 · {new Date(state.activeGrant.expiresAt).toLocaleString()} 到期</span></div>}</div></div>
|
||||
<div className="protocol-panel"><h2>Bridge 方法</h2><div><code>browser.tabs / frames</code><span>列出授权标签页与完整 frame inventory</span></div><div><code>browser.context</code><span>生成结构化快照、存储 inventory、认证信号与上下文 diff</span></div><div><code>browser.node.*</code><span>检查或操作快照内的文档绑定节点引用</span></div><div><code>browser.cookies</code><span>读取指定标签页的浏览器 Cookie</span></div><div><code>browser.network.*</code><span>控制有界网络捕获、读取请求时间线并导出重放包</span></div><div><code>browser.takeover</code><span>将页面切到前台,交给用户扫码或二次验证</span></div><div><code>browser.invoke</code><span>以控制权限调用页面已有全局函数</span></div><div><code>browser.eval</code><span>以控制权限在页面主世界执行代码,支持 Promise 和超时</span></div><div><code>proxy.list / switch</code><span>读取并切换扩展代理配置</span></div></div>
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
html { scrollbar-gutter: stable; }
|
||||
html, body, #app { min-width: 320px; min-height: 100%; margin: 0; }
|
||||
|
||||
@@ -1,453 +0,0 @@
|
||||
type ObservationKind = 'fetch' | 'xhr' | 'form' | 'websocket' | 'webcrypto' | 'cryptojs';
|
||||
|
||||
interface ObserverOptions {
|
||||
captureValues: boolean;
|
||||
maxEntries: number;
|
||||
maxValueBytes: number;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
interface ObserverRecord {
|
||||
id: string;
|
||||
sequence: number;
|
||||
timestamp: number;
|
||||
kind: ObservationKind;
|
||||
operation: string;
|
||||
url?: string;
|
||||
method?: string;
|
||||
algorithm?: string;
|
||||
direction?: 'send' | 'receive';
|
||||
socketId?: string;
|
||||
byteLength?: number;
|
||||
resultByteLength?: number;
|
||||
dataType?: string;
|
||||
stack?: string;
|
||||
scriptUrl?: string;
|
||||
sensitiveCaptured: boolean;
|
||||
inputPreview?: string;
|
||||
outputPreview?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ObserverSnapshot {
|
||||
version: 2;
|
||||
active: boolean;
|
||||
startedAt?: number;
|
||||
count: number;
|
||||
droppedCount: number;
|
||||
options?: ObserverOptions;
|
||||
records: ObserverRecord[];
|
||||
}
|
||||
|
||||
interface ObserverController {
|
||||
version: 2;
|
||||
command(command: 'start' | 'status' | 'list' | 'clear' | 'stop', input?: Partial<ObserverOptions> & { limit?: number }): ObserverSnapshot;
|
||||
}
|
||||
|
||||
interface LegacyObserverController {
|
||||
version?: unknown;
|
||||
command?: (command: 'stop', input?: Record<string, never>) => unknown;
|
||||
}
|
||||
|
||||
type ObserverRecordInput = Omit<ObserverRecord, 'id' | 'sequence' | 'timestamp' | 'sensitiveCaptured'>;
|
||||
|
||||
export default defineUnlistedScript(() => {
|
||||
const REGISTRY_KEY = '__YAKIT_PAGE_OBSERVER_V2__';
|
||||
const LEGACY_REGISTRY_KEY = '__YAKIT_PAGE_OBSERVER_V1__';
|
||||
const registry = window as unknown as Record<string, unknown>;
|
||||
const existing = registry[REGISTRY_KEY] as ObserverController | undefined;
|
||||
if (existing?.version === 2) return;
|
||||
const legacy = registry[LEGACY_REGISTRY_KEY] as LegacyObserverController | undefined;
|
||||
try {
|
||||
if (legacy?.version === 1 && typeof legacy.command === 'function') legacy.command('stop');
|
||||
} catch {
|
||||
// A stale observer must not block the current controller from installing.
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const restorers: Array<() => void> = [];
|
||||
let cryptoJsTimer: number | undefined;
|
||||
let expiryTimer: number | undefined;
|
||||
let active = false;
|
||||
let startedAt: number | undefined;
|
||||
let observationSession = 0;
|
||||
let sequence = 0;
|
||||
let socketSequence = 0;
|
||||
let droppedCount = 0;
|
||||
let records: ObserverRecord[] = [];
|
||||
let options: ObserverOptions = { captureValues: false, maxEntries: 100, maxValueBytes: 2_048 };
|
||||
|
||||
function dataType(value: unknown): string {
|
||||
if (value === null) return 'null';
|
||||
if (value === undefined) return 'undefined';
|
||||
if (typeof value !== 'object') return typeof value;
|
||||
return Object.prototype.toString.call(value).slice(8, -1);
|
||||
}
|
||||
|
||||
function byteLength(value: unknown): number | undefined {
|
||||
try {
|
||||
if (typeof value === 'string') return encoder.encode(value).byteLength;
|
||||
if (value instanceof Blob) return value.size;
|
||||
if (value instanceof ArrayBuffer) return value.byteLength;
|
||||
if (ArrayBuffer.isView(value)) return value.byteLength;
|
||||
if (value instanceof URLSearchParams) return encoder.encode(value.toString()).byteLength;
|
||||
if (typeof FormData !== 'undefined' && value instanceof FormData) {
|
||||
let total = 0;
|
||||
for (const [key, item] of value.entries()) total += encoder.encode(key).byteLength + (typeof item === 'string' ? encoder.encode(item).byteLength : item.size);
|
||||
return total;
|
||||
}
|
||||
if (value && typeof value === 'object' && typeof (value as { sigBytes?: unknown }).sigBytes === 'number') {
|
||||
return Math.max(0, (value as { sigBytes: number }).sigBytes);
|
||||
}
|
||||
if (value !== undefined) return encoder.encode(JSON.stringify(value)).byteLength;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function preview(value: unknown): string | undefined {
|
||||
if (!options.captureValues || value === undefined) return undefined;
|
||||
try {
|
||||
let output: string;
|
||||
if (typeof value === 'string') output = value;
|
||||
else if (value instanceof URLSearchParams) output = value.toString();
|
||||
else if (value instanceof ArrayBuffer || ArrayBuffer.isView(value) || value instanceof Blob) output = `[binary ${byteLength(value) || 0} bytes]`;
|
||||
else if (typeof FormData !== 'undefined' && value instanceof FormData) {
|
||||
output = JSON.stringify([...value.entries()].map(([key, item]) => [key, typeof item === 'string' ? item : `[file ${item.size} bytes]`]));
|
||||
} else if (value && typeof value === 'object' && typeof (value as { toString?: unknown }).toString === 'function') {
|
||||
const cryptoText = (value as { toString(): string }).toString();
|
||||
output = cryptoText === '[object Object]' ? JSON.stringify(value) : cryptoText;
|
||||
} else output = String(value);
|
||||
const bytes = encoder.encode(output);
|
||||
if (bytes.byteLength <= options.maxValueBytes) return output;
|
||||
return new TextDecoder().decode(bytes.slice(0, options.maxValueBytes));
|
||||
} catch {
|
||||
return `[${dataType(value)}]`;
|
||||
}
|
||||
}
|
||||
|
||||
function stackInfo(): { stack?: string; scriptUrl?: string } {
|
||||
try {
|
||||
const stack = new Error().stack?.split('\n').slice(2, 10).join('\n').slice(0, 4_096);
|
||||
const scriptUrl = stack?.match(/https?:\/\/[^\s)]+/)?.[0]?.slice(0, 2_048);
|
||||
return { stack, scriptUrl };
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function record(input: ObserverRecordInput): ObserverRecord | undefined {
|
||||
if (!active) return undefined;
|
||||
const nextSequence = sequence + 1;
|
||||
const item: ObserverRecord = {
|
||||
id: `observation-${startedAt || Date.now()}-${observationSession}-${nextSequence}`,
|
||||
sequence: nextSequence,
|
||||
timestamp: Date.now(),
|
||||
sensitiveCaptured: options.captureValues,
|
||||
...input,
|
||||
};
|
||||
sequence = nextSequence;
|
||||
records.push(item);
|
||||
while (records.length > options.maxEntries) {
|
||||
records.shift();
|
||||
droppedCount += 1;
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
function observe(factory: () => ObserverRecordInput): ObserverRecord | undefined {
|
||||
if (!active) return undefined;
|
||||
try {
|
||||
return record(factory());
|
||||
} catch {
|
||||
droppedCount += 1;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function bestEffort(operation: () => void): void {
|
||||
try {
|
||||
operation();
|
||||
} catch {
|
||||
// Observation is diagnostic and must never change the target page's behavior.
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
try {
|
||||
return (error instanceof Error ? error.message : String(error)).slice(0, 512);
|
||||
} catch {
|
||||
return 'Unknown error';
|
||||
}
|
||||
}
|
||||
|
||||
function algorithmSummary(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') return value.slice(0, 160);
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const algorithm = value as Record<string, unknown>;
|
||||
const name = typeof algorithm.name === 'string' ? algorithm.name : 'unknown';
|
||||
const parts = [name];
|
||||
if (typeof algorithm.namedCurve === 'string') parts.push(`curve=${algorithm.namedCurve}`);
|
||||
if (typeof algorithm.length === 'number') parts.push(`length=${algorithm.length}`);
|
||||
if (typeof algorithm.tagLength === 'number') parts.push(`tag=${algorithm.tagLength}`);
|
||||
const hash = algorithm.hash;
|
||||
if (typeof hash === 'string') parts.push(`hash=${hash}`);
|
||||
else if (hash && typeof hash === 'object' && typeof (hash as { name?: unknown }).name === 'string') parts.push(`hash=${(hash as { name: string }).name}`);
|
||||
if (algorithm.iv !== undefined) parts.push(`ivBytes=${byteLength(algorithm.iv) || 0}`);
|
||||
if (algorithm.salt !== undefined) parts.push(`saltBytes=${byteLength(algorithm.salt) || 0}`);
|
||||
return parts.join(' ').slice(0, 240);
|
||||
}
|
||||
|
||||
function patchFetch(): void {
|
||||
const original = window.fetch;
|
||||
if (typeof original !== 'function') return;
|
||||
const wrapped: typeof window.fetch = function observedFetch(this: Window, input, init) {
|
||||
observe(() => {
|
||||
const request = typeof Request !== 'undefined' && input instanceof Request ? input : undefined;
|
||||
const url = request?.url || String(input);
|
||||
const method = init?.method || request?.method || 'GET';
|
||||
const body = init?.body;
|
||||
return { kind: 'fetch', operation: 'fetch', url: url.slice(0, 8_192), method: method.toUpperCase().slice(0, 32), byteLength: byteLength(body), dataType: dataType(body), inputPreview: preview(body), ...stackInfo() };
|
||||
});
|
||||
return Reflect.apply(original, this, [input, init]);
|
||||
};
|
||||
window.fetch = wrapped;
|
||||
restorers.push(() => { if (window.fetch === wrapped) window.fetch = original; });
|
||||
}
|
||||
|
||||
function patchXhr(): void {
|
||||
if (typeof XMLHttpRequest === 'undefined') return;
|
||||
const states = new WeakMap<XMLHttpRequest, { method: string; url: string; headerCount: number }>();
|
||||
const prototype = XMLHttpRequest.prototype;
|
||||
const originalOpen = prototype.open;
|
||||
const originalSend = prototype.send;
|
||||
const originalSetHeader = prototype.setRequestHeader;
|
||||
const wrappedOpen = function observedOpen(this: XMLHttpRequest, method: string, url: string | URL, ...rest: unknown[]) {
|
||||
bestEffort(() => {
|
||||
states.set(this, { method: String(method).toUpperCase().slice(0, 32), url: String(url).slice(0, 8_192), headerCount: 0 });
|
||||
});
|
||||
return Reflect.apply(originalOpen, this, [method, url, ...rest] as Parameters<XMLHttpRequest['open']>);
|
||||
} as typeof prototype.open;
|
||||
const wrappedSetHeader = function observedSetRequestHeader(this: XMLHttpRequest, name: string, value: string) {
|
||||
bestEffort(() => {
|
||||
const state = states.get(this);
|
||||
if (state) state.headerCount += 1;
|
||||
});
|
||||
return Reflect.apply(originalSetHeader, this, [name, value]);
|
||||
};
|
||||
const wrappedSend = function observedSend(this: XMLHttpRequest, body?: Document | XMLHttpRequestBodyInit | null) {
|
||||
observe(() => {
|
||||
const state = states.get(this);
|
||||
return { kind: 'xhr', operation: 'send', url: state?.url, method: state?.method, byteLength: byteLength(body), dataType: dataType(body), inputPreview: preview(body), ...stackInfo() };
|
||||
});
|
||||
return Reflect.apply(originalSend, this, [body]);
|
||||
};
|
||||
const restore = () => {
|
||||
if (prototype.open === wrappedOpen) prototype.open = originalOpen;
|
||||
if (prototype.setRequestHeader === wrappedSetHeader) prototype.setRequestHeader = originalSetHeader;
|
||||
if (prototype.send === wrappedSend) prototype.send = originalSend;
|
||||
};
|
||||
try {
|
||||
prototype.open = wrappedOpen;
|
||||
prototype.setRequestHeader = wrappedSetHeader;
|
||||
prototype.send = wrappedSend;
|
||||
} catch (error) {
|
||||
bestEffort(restore);
|
||||
throw error;
|
||||
}
|
||||
restorers.push(restore);
|
||||
}
|
||||
|
||||
function patchForms(): void {
|
||||
const onSubmit = (event: Event) => {
|
||||
const form = event.target instanceof HTMLFormElement ? event.target : undefined;
|
||||
if (!form) return;
|
||||
observe(() => {
|
||||
let body: FormData | undefined;
|
||||
try { body = new FormData(form); } catch { /* Some custom forms cannot be serialized. */ }
|
||||
return {
|
||||
kind: 'form', operation: 'submit', url: form.action.slice(0, 8_192), method: form.method.toUpperCase().slice(0, 32),
|
||||
byteLength: byteLength(body), dataType: 'FormData', inputPreview: preview(body), ...stackInfo(),
|
||||
};
|
||||
});
|
||||
};
|
||||
document.addEventListener('submit', onSubmit, true);
|
||||
restorers.push(() => document.removeEventListener('submit', onSubmit, true));
|
||||
}
|
||||
|
||||
function patchWebSocket(): void {
|
||||
const Original = window.WebSocket;
|
||||
if (typeof Original !== 'function') return;
|
||||
const Wrapped = new Proxy(Original, {
|
||||
construct(target, args) {
|
||||
const socket = Reflect.construct(target, args) as WebSocket;
|
||||
bestEffort(() => {
|
||||
const socketId = `socket-${startedAt || Date.now()}-${observationSession}-${++socketSequence}`;
|
||||
const socketUrl = String(args[0] || '').slice(0, 8_192);
|
||||
observe(() => ({ kind: 'websocket', operation: 'construct', url: socketUrl, socketId, ...stackInfo() }));
|
||||
const originalSend = socket.send;
|
||||
const wrappedSend = function observedSend(this: WebSocket, data: string | ArrayBufferLike | Blob | ArrayBufferView) {
|
||||
observe(() => ({ kind: 'websocket', operation: 'frame', direction: 'send', url: socketUrl, socketId, byteLength: byteLength(data), dataType: dataType(data), inputPreview: preview(data), ...stackInfo() }));
|
||||
return Reflect.apply(originalSend, this, [data]);
|
||||
};
|
||||
const onOpen = () => observe(() => ({ kind: 'websocket', operation: 'open', url: socketUrl, socketId }));
|
||||
const onMessage = (event: MessageEvent) => observe(() => ({ kind: 'websocket', operation: 'frame', direction: 'receive', url: socketUrl, socketId, byteLength: byteLength(event.data), dataType: dataType(event.data), outputPreview: preview(event.data) }));
|
||||
const onClose = (event: CloseEvent) => observe(() => ({ kind: 'websocket', operation: 'close', url: socketUrl, socketId, error: event.wasClean ? undefined : `code=${event.code}` }));
|
||||
const onError = () => observe(() => ({ kind: 'websocket', operation: 'error', url: socketUrl, socketId, error: 'WebSocket error' }));
|
||||
restorers.push(() => {
|
||||
if (socket.send === wrappedSend) socket.send = originalSend;
|
||||
socket.removeEventListener('open', onOpen);
|
||||
socket.removeEventListener('message', onMessage);
|
||||
socket.removeEventListener('close', onClose);
|
||||
socket.removeEventListener('error', onError);
|
||||
});
|
||||
socket.send = wrappedSend;
|
||||
socket.addEventListener('open', onOpen);
|
||||
socket.addEventListener('message', onMessage);
|
||||
socket.addEventListener('close', onClose);
|
||||
socket.addEventListener('error', onError);
|
||||
});
|
||||
return socket;
|
||||
},
|
||||
});
|
||||
window.WebSocket = Wrapped;
|
||||
restorers.push(() => { if (window.WebSocket === Wrapped) window.WebSocket = Original; });
|
||||
}
|
||||
|
||||
function patchWebCrypto(): void {
|
||||
const subtle = globalThis.crypto?.subtle;
|
||||
if (!subtle) return;
|
||||
const prototype = Object.getPrototypeOf(subtle) as Record<string, unknown>;
|
||||
const operations = ['encrypt', 'decrypt', 'sign', 'verify', 'digest', 'deriveBits', 'deriveKey', 'generateKey', 'importKey', 'exportKey', 'wrapKey', 'unwrapKey'] as const;
|
||||
for (const operation of operations) {
|
||||
const original = prototype[operation];
|
||||
if (typeof original !== 'function') continue;
|
||||
const wrapped = function observedWebCrypto(this: SubtleCrypto, ...args: unknown[]) {
|
||||
const item = observe(() => {
|
||||
const input = args.find((value, index) => index > 0 && (typeof value === 'string' || value instanceof ArrayBuffer || ArrayBuffer.isView(value)));
|
||||
return { kind: 'webcrypto', operation, algorithm: algorithmSummary(args[0]), byteLength: byteLength(input), dataType: dataType(input), inputPreview: preview(input), ...stackInfo() };
|
||||
});
|
||||
try {
|
||||
const result = Reflect.apply(original, this, args) as Promise<unknown>;
|
||||
void result.then((output) => {
|
||||
if (item) {
|
||||
item.resultByteLength = byteLength(output);
|
||||
item.outputPreview = preview(output);
|
||||
}
|
||||
}, (error) => { if (item) item.error = errorMessage(error); });
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (item) item.error = errorMessage(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
prototype[operation] = wrapped;
|
||||
restorers.push(() => { if (prototype[operation] === wrapped) prototype[operation] = original; });
|
||||
}
|
||||
}
|
||||
|
||||
const cryptoJsRestorers: Array<() => void> = [];
|
||||
const cryptoJsWrappers = new WeakSet<Function>();
|
||||
function patchCryptoJs(): void {
|
||||
const cryptoJs = (window as unknown as { CryptoJS?: Record<string, unknown> }).CryptoJS;
|
||||
if (!cryptoJs) return;
|
||||
const paths = [
|
||||
'AES.encrypt', 'AES.decrypt', 'DES.encrypt', 'DES.decrypt', 'TripleDES.encrypt', 'TripleDES.decrypt',
|
||||
'RC4.encrypt', 'RC4.decrypt', 'Rabbit.encrypt', 'Rabbit.decrypt', 'MD5', 'SHA1', 'SHA224', 'SHA256',
|
||||
'SHA384', 'SHA512', 'SHA3', 'RIPEMD160', 'HmacMD5', 'HmacSHA1', 'HmacSHA224', 'HmacSHA256',
|
||||
'HmacSHA384', 'HmacSHA512', 'PBKDF2', 'EvpKDF',
|
||||
];
|
||||
for (const path of paths) {
|
||||
const segments = path.split('.');
|
||||
let owner: Record<string, unknown> = cryptoJs;
|
||||
for (const segment of segments.slice(0, -1)) {
|
||||
const next = owner[segment];
|
||||
if (!next || typeof next !== 'object') { owner = {}; break; }
|
||||
owner = next as Record<string, unknown>;
|
||||
}
|
||||
const key = segments.at(-1)!;
|
||||
const original = owner[key];
|
||||
if (typeof original !== 'function' || cryptoJsWrappers.has(original)) continue;
|
||||
const wrapped = function observedCryptoJs(this: unknown, ...args: unknown[]) {
|
||||
const item = observe(() => ({ kind: 'cryptojs', operation: path, algorithm: path.split('.')[0], byteLength: byteLength(args[0]), dataType: dataType(args[0]), inputPreview: preview(args[0]), ...stackInfo() }));
|
||||
try {
|
||||
const output = Reflect.apply(original, this, args);
|
||||
if (item) {
|
||||
item.resultByteLength = byteLength(output);
|
||||
item.outputPreview = preview(output);
|
||||
}
|
||||
return output;
|
||||
} catch (error) {
|
||||
if (item) item.error = errorMessage(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
owner[key] = wrapped;
|
||||
cryptoJsWrappers.add(wrapped);
|
||||
const restore = () => { if (owner[key] === wrapped) owner[key] = original; };
|
||||
cryptoJsRestorers.push(restore);
|
||||
}
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
active = false;
|
||||
if (expiryTimer !== undefined) window.clearTimeout(expiryTimer);
|
||||
if (cryptoJsTimer !== undefined) window.clearInterval(cryptoJsTimer);
|
||||
expiryTimer = undefined;
|
||||
cryptoJsTimer = undefined;
|
||||
while (cryptoJsRestorers.length) {
|
||||
const restore = cryptoJsRestorers.pop();
|
||||
if (restore) bestEffort(restore);
|
||||
}
|
||||
while (restorers.length) {
|
||||
const restore = restorers.pop();
|
||||
if (restore) bestEffort(restore);
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(limit = options.maxEntries): ObserverSnapshot {
|
||||
return {
|
||||
version: 2,
|
||||
active,
|
||||
startedAt,
|
||||
count: records.length,
|
||||
droppedCount,
|
||||
options: startedAt ? { ...options } : undefined,
|
||||
records: records.slice(-Math.max(0, Math.min(limit, options.maxEntries))),
|
||||
};
|
||||
}
|
||||
|
||||
const controller: ObserverController = {
|
||||
version: 2,
|
||||
command(command, input = {}) {
|
||||
if (command === 'start') {
|
||||
stop();
|
||||
options = {
|
||||
captureValues: input.captureValues === true,
|
||||
maxEntries: Math.max(10, Math.min(Number(input.maxEntries) || 100, 200)),
|
||||
maxValueBytes: Math.max(256, Math.min(Number(input.maxValueBytes) || 2_048, 8_192)),
|
||||
expiresAt: typeof input.expiresAt === 'number' ? input.expiresAt : undefined,
|
||||
};
|
||||
records = [];
|
||||
droppedCount = 0;
|
||||
sequence = 0;
|
||||
socketSequence = 0;
|
||||
observationSession += 1;
|
||||
startedAt = Date.now();
|
||||
active = true;
|
||||
for (const patch of [patchFetch, patchXhr, patchForms, patchWebSocket, patchWebCrypto, patchCryptoJs]) {
|
||||
bestEffort(patch);
|
||||
}
|
||||
cryptoJsTimer = window.setInterval(() => bestEffort(patchCryptoJs), 1_000);
|
||||
if (options.expiresAt) expiryTimer = window.setTimeout(stop, Math.max(0, options.expiresAt - Date.now()));
|
||||
} else if (command === 'clear') {
|
||||
records = [];
|
||||
droppedCount = 0;
|
||||
} else if (command === 'stop') stop();
|
||||
return snapshot(typeof input.limit === 'number' ? input.limit : options.maxEntries);
|
||||
},
|
||||
};
|
||||
|
||||
Object.defineProperty(registry, REGISTRY_KEY, { value: controller, configurable: true, enumerable: false, writable: false });
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
+174
-39
@@ -1,34 +1,69 @@
|
||||
.popup-shell { width: 390px; display: flex; flex-direction: column; background: var(--surface); }
|
||||
.popup-shell { position: relative; width: 390px; height: 600px; min-height: 600px; display: flex; flex-direction: column; overflow: hidden; background: var(--surface); }
|
||||
|
||||
/* Header */
|
||||
.popup-header { padding: 12px 16px 10px; border-bottom: 1px solid var(--border); color: var(--foreground); }
|
||||
.popup-brand-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.popup-brand-actions { display: flex; align-items: center; gap: 4px; }
|
||||
.popup-header { padding: 9px 13px; border-bottom: 1px solid var(--border); color: var(--foreground); background: var(--surface); }
|
||||
.popup-header-main { min-height: 42px; display: grid; grid-template-columns: 32px minmax(0, 1fr) auto; align-items: center; gap: 10px; }
|
||||
.popup-brand-mark { width: 32px; height: 32px; padding: 1px; display: grid; place-items: center; border-radius: 9px; background: transparent; cursor: help; }
|
||||
.popup-brand-mark:hover, .popup-brand-mark:focus-visible { background: var(--surface-subtle); outline: none; }
|
||||
.popup-brand-mark .yak-mark { width: 28px; height: 28px; object-fit: contain; }
|
||||
.popup-target { min-width: 0; display: grid; gap: 1px; }
|
||||
.popup-target-title { min-width: 0; display: flex; align-items: center; gap: 6px; }
|
||||
.popup-target-title strong { min-width: 0; overflow: hidden; font-size: var(--text-sm); line-height: 17px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.popup-target-host { overflow: hidden; color: var(--muted); font-size: var(--text-xs); line-height: 14px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.popup-brand-actions { display: flex; align-items: center; gap: 3px; }
|
||||
.popup-brand-actions .ui-button { color: var(--muted-strong); }
|
||||
.popup-brand-actions .ui-button:hover { background: var(--surface-subtle); color: var(--foreground); }
|
||||
.popup-engine-pill { height: 26px; padding: 0 10px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface-subtle); color: var(--muted-strong); font-size: var(--text-sm); font-weight: 600; white-space: nowrap; cursor: pointer; transition: background-color .15s ease, border-color .15s ease; }
|
||||
.popup-engine-pill:hover { background: var(--border); }
|
||||
.popup-engine-pill:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.popup-engine-pill i { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
|
||||
.popup-engine-pill.connected { border-color: color-mix(in srgb, var(--success) 40%, var(--surface)); background: var(--success-soft); color: var(--success); }
|
||||
.popup-engine-pill.connected i { background: var(--success); }
|
||||
.popup-engine-pill.connecting i, .popup-engine-pill.negotiating i { background: var(--warning); animation: pulse 1.3s infinite; }
|
||||
.popup-engine-pill.error i { background: var(--danger); }
|
||||
.popup-tab-line { min-width: 0; margin: 8px -6px 0; padding: 3px 6px; display: flex; align-items: center; gap: 7px; border-radius: 4px; color: var(--muted); font-size: var(--text-sm); line-height: 16px; }
|
||||
.popup-tab-line:hover { background: var(--surface-subtle); }
|
||||
.popup-tab-line > span:last-child { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.popup-favicon { width: 16px; height: 16px; flex: 0 0 auto; display: grid; place-items: center; }
|
||||
.popup-engine-status { height: 26px; padding: 0 9px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface-subtle); color: var(--muted-strong); font-size: var(--text-xs); font-weight: 600; white-space: nowrap; cursor: pointer; transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease; }
|
||||
.popup-engine-status:hover { background: var(--border); }
|
||||
.popup-engine-status:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--focus); }
|
||||
.popup-engine-status:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.popup-engine-status i { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
|
||||
.popup-engine-status.connected { border-color: color-mix(in srgb, var(--success) 40%, var(--surface)); background: var(--success-soft); color: var(--success); }
|
||||
.popup-engine-status.connected i { background: var(--success); }
|
||||
.popup-engine-status.connecting i, .popup-engine-status.negotiating i { background: var(--warning); animation: pulse 1.3s infinite; }
|
||||
.popup-engine-status.error { border-color: color-mix(in srgb, var(--danger) 35%, var(--surface)); background: var(--danger-soft); color: var(--danger); }
|
||||
.popup-engine-status.error i { background: var(--danger); }
|
||||
.popup-favicon { width: 16px; height: 16px; flex: 0 0 auto; display: grid; place-items: center; color: var(--muted); }
|
||||
.popup-favicon img { width: 16px; height: 16px; object-fit: contain; }
|
||||
|
||||
/* Icon rail and workspace */
|
||||
.popup-body { min-height: 0; flex: 1; display: grid; grid-template-columns: 50px minmax(0, 1fr); background: var(--background); }
|
||||
.popup-rail { min-height: 0; padding: 9px 7px; display: flex; flex-direction: column; justify-content: space-between; border-right: 1px solid var(--border); background: var(--surface-subtle); }
|
||||
.popup-rail-main, .popup-rail-bottom { display: grid; justify-items: center; gap: 6px; }
|
||||
.popup-rail-bottom { padding-top: 9px; border-top: 1px solid var(--border); }
|
||||
.popup-rail button { width: 36px; height: 36px; padding: 0; display: grid; place-items: center; border: 1px solid transparent; border-radius: 9px; background: transparent; color: var(--muted-strong); cursor: pointer; transition: background-color .14s ease, border-color .14s ease, color .14s ease, box-shadow .14s ease; }
|
||||
.popup-rail button:hover { background: var(--border); color: var(--foreground); }
|
||||
.popup-rail button:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--focus); }
|
||||
.popup-rail button.is-active { border-color: color-mix(in srgb, var(--primary) 25%, var(--border)); background: var(--surface); color: var(--primary-strong); box-shadow: var(--shadow-sm); }
|
||||
.popup-workspace { min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; background: var(--background); }
|
||||
.popup-overview-view { min-height: 0; flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.popup-overview-lead { padding: 7px 15px; border-bottom: 1px solid var(--border); background: var(--surface); }
|
||||
.popup-overview-lead__meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: var(--text-xs); line-height: 16px; }
|
||||
.popup-overview-lead__meta > span { overflow: hidden; color: var(--muted); white-space: nowrap; text-overflow: ellipsis; }
|
||||
.popup-overview-lead__meta strong { display: inline-flex; align-items: center; gap: 5px; color: var(--success); font-weight: 600; white-space: nowrap; }
|
||||
.popup-overview-lead__meta strong.is-unavailable { color: var(--muted-strong); }
|
||||
.popup-overview-lead__meta i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.popup-overview-summary { min-height: 0; flex: 1; padding: 0 12px; overflow-y: auto; scrollbar-width: thin; background: var(--surface); }
|
||||
.popup-overview-summary > button { width: 100%; min-height: 55px; padding: 8px 2px; display: grid; grid-template-columns: 28px minmax(0, 1fr) 16px; align-items: center; gap: 9px; border: 0; border-bottom: 1px solid var(--border); background: transparent; color: var(--foreground); text-align: left; cursor: pointer; transition: background-color .14s ease, padding-left .14s ease; }
|
||||
.popup-overview-summary > button:hover { padding-left: 5px; background: var(--surface-subtle); }
|
||||
.popup-overview-summary > button:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--focus); }
|
||||
.popup-overview-summary > button > span:nth-child(2) { min-width: 0; display: grid; gap: 2px; }
|
||||
.popup-overview-summary small, .popup-overview-summary strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.popup-overview-summary small { color: var(--muted); font-size: var(--text-xs); line-height: 14px; }
|
||||
.popup-overview-summary strong { font-size: var(--text-sm); line-height: 17px; }
|
||||
.popup-overview-summary > button > svg { color: var(--muted); }
|
||||
.popup-overview-icon { width: 27px; height: 27px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--muted-strong); }
|
||||
.popup-overview-summary > button:hover .popup-overview-icon { background: var(--primary-soft); color: var(--primary); }
|
||||
|
||||
/* 人工接管 —— 内嵌警告卡 */
|
||||
.popup-handoff { margin: 10px 12px; display: grid; grid-template-columns: 20px minmax(0, 1fr) auto; gap: 10px; align-items: start; padding: 12px 14px 12px 13px; border-left: 3px solid var(--warning); border-radius: var(--radius-md); background: var(--warning-soft); }
|
||||
.popup-handoff { margin: 10px 12px; display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 8px 10px; align-items: start; padding: 12px 14px 12px 13px; border-left: 3px solid var(--warning); border-radius: var(--radius-md); background: var(--warning-soft); }
|
||||
.popup-handoff > svg { margin-top: 1px; color: var(--warning); }
|
||||
.popup-handoff__copy { min-width: 0; }
|
||||
.popup-handoff__copy strong, .popup-handoff__copy span, .popup-handoff__copy small { display: block; }
|
||||
.popup-handoff__copy strong { font-size: var(--text-md); font-weight: 600; line-height: 18px; }
|
||||
.popup-handoff__copy span { margin-top: 3px; font-size: var(--text-sm); line-height: 16px; overflow-wrap: anywhere; }
|
||||
.popup-handoff__copy small { margin-top: 4px; overflow: hidden; color: var(--muted); font-size: var(--text-sm); line-height: 16px; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.popup-handoff__actions { display: flex; gap: 4px; align-items: center; }
|
||||
.popup-handoff__actions { grid-column: 2; display: flex; gap: 6px; align-items: center; }
|
||||
.popup-handoff__actions .ui-button { white-space: nowrap; }
|
||||
.popup-handoff__actions .ui-button--icon { width: 30px; height: 30px; }
|
||||
|
||||
@@ -43,35 +78,135 @@
|
||||
.popup-share-copy span { margin-top: 2px; color: var(--muted); font-size: var(--text-sm); line-height: 16px; }
|
||||
.popup-share.is-active .popup-share-copy span { color: var(--success); }
|
||||
|
||||
/* 代理快切 */
|
||||
.popup-proxy { padding: 10px 12px 12px; border-bottom: 1px solid var(--border); }
|
||||
.popup-section-label { min-height: 22px; margin-bottom: 7px; padding: 0 4px; display: flex; align-items: center; gap: 7px; color: var(--muted-strong); font-size: var(--text-sm); font-weight: 600; line-height: 16px; }
|
||||
.popup-section-label .ui-badge { margin-left: auto; }
|
||||
.popup-proxy-list { max-height: 172px; overflow-y: auto; display: grid; gap: 3px; scrollbar-width: thin; }
|
||||
.popup-proxy-list > button { width: 100%; min-height: 40px; padding: 4px 10px; display: flex; align-items: center; gap: 10px; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--foreground); text-align: left; cursor: pointer; transition: background-color .13s ease; }
|
||||
/* 代理:站点路由与全局模式 */
|
||||
.popup-proxy-view { min-height: 0; }
|
||||
.popup-site-router { padding: 10px 12px 9px; display: grid; gap: 7px; border-bottom: 1px solid var(--border); background: var(--surface); }
|
||||
.popup-site-router__heading { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.popup-site-router__heading > div { min-width: 0; display: flex; align-items: center; gap: 8px; }
|
||||
.popup-site-router__heading > div > svg { flex: 0 0 auto; color: var(--primary); }
|
||||
.popup-site-router__heading span { min-width: 0; }
|
||||
.popup-site-router__heading small, .popup-site-router__heading strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.popup-site-router__heading small { color: var(--muted); font-size: var(--text-xs); line-height: 13px; }
|
||||
.popup-site-router__heading strong { max-width: 215px; margin-top: 1px; font-size: var(--text-sm); line-height: 17px; }
|
||||
.popup-site-router__heading > i { flex: 0 0 auto; padding: 2px 6px; border-radius: 4px; background: var(--surface-subtle); color: var(--muted-strong); font-size: var(--text-xs); font-style: normal; font-weight: 600; }
|
||||
.popup-site-router__heading > i.manual { background: var(--primary-soft); color: var(--primary-text); }
|
||||
.popup-site-router__heading > i.source { background: var(--success-soft); color: var(--success); }
|
||||
.popup-site-router__heading > i.global { background: var(--warning-soft); color: var(--warning); }
|
||||
.popup-site-decision { min-width: 0; min-height: 27px; padding: 5px 8px; display: grid; grid-template-columns: minmax(0, 1fr) 12px minmax(0, .8fr); align-items: center; gap: 5px; border-left: 2px solid var(--primary); background: var(--surface-subtle); }
|
||||
.popup-site-decision span, .popup-site-decision strong { overflow: hidden; font-size: var(--text-xs); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.popup-site-decision span { color: var(--muted-strong); }
|
||||
.popup-site-decision i { color: var(--muted); font-size: var(--text-xs); font-style: normal; text-align: center; }
|
||||
.popup-site-decision strong { color: var(--foreground); }
|
||||
.popup-site-picker { display: grid; gap: 5px; }
|
||||
.popup-site-picker > label { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--muted-strong); font-size: var(--text-xs); font-weight: 600; }
|
||||
.popup-site-picker > label span { color: var(--muted); font-weight: 400; }
|
||||
.popup-site-picker select { min-width: 0; height: 32px; padding: 0 9px; font-size: var(--text-xs); }
|
||||
.popup-site-status { min-width: 0; height: 15px; display: flex; align-items: center; gap: 5px; color: var(--muted); }
|
||||
.popup-site-status svg { flex: 0 0 auto; }
|
||||
.popup-site-status small { overflow: hidden; font-size: var(--text-xs); line-height: 15px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.popup-site-status.is-applying { color: var(--primary-text); }
|
||||
.popup-site-status.is-success { color: var(--success); }
|
||||
.popup-site-status.is-error { color: var(--danger); }
|
||||
.popup-proxy-unavailable { min-height: 118px; padding: 18px 14px; display: flex; align-items: center; justify-content: center; gap: 9px; border-bottom: 1px solid var(--border); color: var(--muted); text-align: left; }
|
||||
.popup-proxy-unavailable strong, .popup-proxy-unavailable small { display: block; }
|
||||
.popup-proxy-unavailable strong { color: var(--foreground); font-size: var(--text-sm); }
|
||||
.popup-proxy-unavailable small { margin-top: 2px; font-size: var(--text-xs); }
|
||||
.popup-mode-heading { min-height: 39px; padding: 6px 12px; display: flex; align-items: center; justify-content: space-between; gap: 10px; border-bottom: 1px solid var(--border); background: var(--surface-subtle); }
|
||||
.popup-mode-heading > span { min-width: 0; }
|
||||
.popup-mode-heading strong, .popup-mode-heading small { display: block; }
|
||||
.popup-mode-heading strong { font-size: var(--text-sm); line-height: 16px; }
|
||||
.popup-mode-heading small { margin-top: 1px; color: var(--muted); font-size: var(--text-xs); line-height: 13px; }
|
||||
.popup-mode-heading > i { max-width: 110px; overflow: hidden; color: var(--muted-strong); font-size: var(--text-xs); font-style: normal; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.popup-proxy-list { overflow-y: auto; display: grid; align-content: start; gap: 2px; scrollbar-width: thin; }
|
||||
.popup-proxy-list--view { min-height: 0; flex: 1; padding: 5px 8px; background: var(--surface); }
|
||||
.popup-proxy-list > button { width: 100%; min-height: 43px; padding: 5px 8px; display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; align-items: center; gap: 8px; border: 1px solid transparent; border-radius: var(--radius-md); background: transparent; color: var(--foreground); text-align: left; cursor: pointer; transition: background-color .13s ease, border-color .13s ease, color .13s ease; }
|
||||
.popup-proxy-list > button:hover { background: var(--surface-subtle); }
|
||||
.popup-proxy-list > button:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--focus); }
|
||||
.popup-proxy-list > button.is-active { background: var(--primary-soft); }
|
||||
.popup-proxy-list > button.is-active { border-color: color-mix(in srgb, var(--primary) 20%, var(--border)); background: var(--primary-soft); }
|
||||
.popup-proxy-list > button.is-active strong { color: var(--primary-text); }
|
||||
.popup-radio { width: 15px; height: 15px; flex: 0 0 auto; padding: 2.5px; border: 1.5px solid var(--border-strong); border-radius: 50%; background-clip: content-box; transition: border-color .13s ease; }
|
||||
.popup-proxy-list > button.is-active .popup-radio { border-color: var(--primary); background-color: var(--primary); }
|
||||
.popup-proxy-list > button > span { min-width: 0; }
|
||||
.popup-proxy-list > button > span:nth-child(2) { min-width: 0; }
|
||||
.popup-proxy-list strong, .popup-proxy-list small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.popup-proxy-list strong { font-size: var(--text-md); font-weight: 600; line-height: 17px; }
|
||||
.popup-proxy-list small { margin-top: 1px; color: var(--muted); font-size: var(--text-sm); line-height: 15px; }
|
||||
.popup-proxy-list strong { font-size: var(--text-sm); font-weight: 600; line-height: 16px; }
|
||||
.popup-proxy-list small { margin-top: 1px; color: var(--muted); font-size: var(--text-xs); line-height: 14px; }
|
||||
.popup-proxy-list > button > svg { color: var(--primary); }
|
||||
.popup-mode-icon { width: 28px; height: 28px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--muted-strong); }
|
||||
.popup-proxy-list > button.is-active .popup-mode-icon { border-color: color-mix(in srgb, var(--primary) 28%, var(--border)); color: var(--primary); }
|
||||
.popup-proxy-list em { padding: 2px 5px; border-radius: 4px; background: var(--warning-soft); color: var(--warning); font-size: var(--text-xs); font-style: normal; white-space: nowrap; }
|
||||
.popup-proxy-view .popup-tool-footer > button { color: var(--primary-text); }
|
||||
|
||||
/* 工具网格 */
|
||||
.popup-tools { padding: 10px 12px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; border-bottom: 1px solid var(--border); }
|
||||
.popup-tools button { padding: 9px 6px 8px; display: grid; justify-items: center; gap: 5px; border: 0; border-radius: var(--radius-md); background: var(--surface-subtle); color: var(--muted-strong); font-size: var(--text-sm); font-weight: 600; cursor: pointer; transition: color .13s ease, background-color .13s ease; }
|
||||
.popup-tools button:hover { background: var(--border); color: var(--foreground); }
|
||||
.popup-tools button:hover > svg { color: var(--primary); }
|
||||
.popup-tools button:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--focus); }
|
||||
.popup-tools button > svg { color: var(--muted-strong); }
|
||||
/* 专注子页面 */
|
||||
.popup-view { min-height: 0; flex: 1; color: var(--foreground); }
|
||||
.popup-tool-view { display: flex; flex-direction: column; overflow: hidden; animation: popup-view-in .16s ease-out; }
|
||||
.popup-tool-context { min-height: 34px; padding: 0 14px; display: grid; grid-template-columns: 16px minmax(0, 1fr) auto; align-items: center; gap: 7px; border-bottom: 1px solid var(--border); background: var(--surface-subtle); color: var(--muted-strong); font-size: var(--text-xs); }
|
||||
.popup-tool-context span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.popup-tool-context strong { padding: 2px 7px; border-radius: 999px; background: var(--surface-subtle); color: var(--foreground); font-size: var(--text-xs); }
|
||||
.popup-view-enter { animation: popup-content-in .16s ease-out; }
|
||||
.popup-tool-toolbar { min-height: 46px; padding: 7px 12px; display: flex; align-items: center; gap: 7px; border-bottom: 1px solid var(--border); background: var(--surface); }
|
||||
.popup-tool-toolbar > label { min-width: 0; flex: 1; height: 32px; padding: 0 9px; display: flex; align-items: center; gap: 7px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--background); color: var(--muted); }
|
||||
.popup-tool-toolbar input { min-width: 0; flex: 1; border: 0; background: transparent; color: var(--foreground); outline: 0; font-size: var(--text-sm); }
|
||||
.popup-tool-empty { min-height: 150px; padding: 24px; display: grid; place-items: center; color: var(--muted); font-size: var(--text-sm); text-align: center; }
|
||||
.popup-tool-footer { min-height: 38px; padding: 6px 14px; display: flex; align-items: center; justify-content: space-between; gap: 10px; border-top: 1px solid var(--border); color: var(--muted); font-size: var(--text-xs); }
|
||||
.popup-tool-footer > button { display: inline-flex; align-items: center; gap: 5px; border: 0; background: transparent; color: var(--danger); font-size: var(--text-xs); cursor: pointer; }
|
||||
.popup-tool-footer > button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
|
||||
/* Popup Cookie Editor */
|
||||
.popup-cookie-list { min-height: 0; flex: 1; overflow-y: auto; background: var(--surface); scrollbar-width: thin; }
|
||||
.popup-cookie-row { min-height: 70px; padding: 9px 10px 8px 14px; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 5px 8px; border-bottom: 1px solid var(--border); transition: background-color .13s ease; }
|
||||
.popup-cookie-row:hover { background: var(--surface-subtle); }
|
||||
.popup-cookie-main { min-width: 0; padding: 0; display: grid; gap: 2px; border: 0; background: transparent; color: var(--foreground); text-align: left; cursor: pointer; }
|
||||
.popup-cookie-main > span { min-width: 0; display: flex; align-items: center; gap: 6px; }
|
||||
.popup-cookie-main strong { overflow: hidden; font-size: var(--text-sm); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.popup-cookie-main > span i { padding: 1px 5px; border-radius: 999px; background: var(--surface-subtle); color: var(--muted-strong); font-size: var(--text-xs); font-style: normal; }
|
||||
.popup-cookie-main code, .popup-cookie-main small { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.popup-cookie-main code { color: var(--muted-strong); font-size: var(--text-xs); }
|
||||
.popup-cookie-main small { color: var(--muted); font-size: var(--text-xs); }
|
||||
.popup-cookie-meta { min-height: 15px; display: flex; align-items: center; gap: 4px; }
|
||||
.popup-cookie-meta i { padding: 1px 4px; border-radius: 3px; background: var(--surface-subtle); color: var(--muted); font-size: var(--text-xs); font-style: normal; }
|
||||
.popup-cookie-actions { grid-column: 2; grid-row: 1 / 3; display: flex; align-items: center; }
|
||||
.popup-cookie-actions button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 5px; background: transparent; color: var(--muted); cursor: pointer; }
|
||||
.popup-cookie-actions button:hover { background: var(--border); color: var(--foreground); }
|
||||
.popup-cookie-actions button.danger:hover { color: var(--danger); }
|
||||
.popup-cookie-editor, .popup-ua-custom { min-height: 0; flex: 1; padding: 13px 16px 15px; overflow-y: auto; display: grid; gap: 11px; align-content: start; }
|
||||
.popup-editor-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
||||
.popup-editor-title strong, .popup-editor-title span { display: block; }
|
||||
.popup-editor-title strong { font-size: var(--text-md); }
|
||||
.popup-editor-title span { margin-top: 2px; color: var(--muted); font-size: var(--text-xs); }
|
||||
.popup-cookie-editor > label, .popup-ua-custom > label, .popup-editor-grid label { display: grid; gap: 5px; color: var(--muted-strong); font-size: var(--text-xs); }
|
||||
.popup-cookie-editor input, .popup-cookie-editor select, .popup-ua-custom input, .popup-ua-custom textarea { width: 100%; min-width: 0; }
|
||||
.popup-editor-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.popup-cookie-flags { display: flex; align-items: center; gap: 18px; }
|
||||
.popup-cookie-flags label { display: flex; align-items: center; gap: 6px; color: var(--muted-strong); font-size: var(--text-xs); }
|
||||
.popup-inline-warning { padding: 8px 10px; border-left: 3px solid var(--warning); background: var(--warning-soft); color: var(--warning); font-size: var(--text-xs); line-height: 16px; }
|
||||
|
||||
/* Popup User-Agent */
|
||||
.popup-ua-current { padding: 9px 14px; display: grid; gap: 2px; border-bottom: 1px solid var(--border); background: var(--surface); }
|
||||
.popup-ua-current > span { color: var(--muted); font-size: var(--text-xs); }
|
||||
.popup-ua-current > strong { font-size: var(--text-md); }
|
||||
.popup-ua-current > code { overflow: hidden; color: var(--muted-strong); font-size: var(--text-xs); white-space: nowrap; text-overflow: ellipsis; }
|
||||
.popup-ua-current > small { margin-top: 3px; color: var(--muted); font-size: var(--text-xs); }
|
||||
.popup-ua-list { min-height: 0; flex: 1; padding: 5px 8px; overflow-y: auto; background: var(--surface); scrollbar-width: thin; }
|
||||
.popup-ua-list > button { width: 100%; min-height: 43px; padding: 5px 8px; display: grid; grid-template-columns: 30px minmax(0, 1fr) 15px; align-items: center; gap: 8px; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--foreground); text-align: left; cursor: pointer; }
|
||||
.popup-ua-list > button:hover { background: var(--surface-subtle); }
|
||||
.popup-ua-list > button.is-selected { background: var(--primary-soft); }
|
||||
.popup-ua-list > button > span:nth-child(2) { min-width: 0; }
|
||||
.popup-ua-list strong, .popup-ua-list small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.popup-ua-list strong { font-size: var(--text-sm); }
|
||||
.popup-ua-list small { margin-top: 1px; color: var(--muted); font-size: var(--text-xs); }
|
||||
.popup-ua-list > button > i { width: 15px; height: 15px; flex: 0 0 auto; padding: 2.5px; border: 1.5px solid var(--border-strong); border-radius: 50%; background-clip: content-box; transition: border-color .13s ease; }
|
||||
.popup-ua-list > button.is-selected > i { border-color: var(--primary); background-color: var(--primary); }
|
||||
.popup-ua-icon { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 7px; background: var(--surface-subtle); color: var(--muted-strong); }
|
||||
.popup-ua-list > button.is-selected .popup-ua-icon { background: var(--surface); color: var(--primary); }
|
||||
.popup-ua-actions { min-height: 52px; padding: 8px 12px; display: flex; justify-content: flex-end; gap: 7px; border-top: 1px solid var(--border); }
|
||||
|
||||
@keyframes popup-view-in { from { opacity: 0; transform: translateX(10px); } to { opacity: 1; transform: translateX(0); } }
|
||||
@keyframes popup-content-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@media (prefers-reduced-motion: reduce) { .popup-tool-view, .popup-view-enter { animation: none; } }
|
||||
|
||||
/* Footer CTA */
|
||||
.popup-footer { margin-top: auto; padding: 10px 16px 12px; }
|
||||
.popup-capture { width: 100%; height: 38px; font-size: var(--text-lg); }
|
||||
.popup-footer { margin-top: auto; padding: 10px 14px 12px; border-top: 1px solid var(--border); background: var(--surface); }
|
||||
.popup-capture { width: 100%; height: 36px; border-radius: 7px; font-size: var(--text-md); box-shadow: 0 1px 0 color-mix(in srgb, var(--primary-strong) 55%, transparent); }
|
||||
.popup-notice { display: block; margin-top: 6px; overflow: hidden; color: var(--muted); font-size: var(--text-sm); line-height: 16px; text-align: center; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.popup-global-notice { position: absolute; z-index: 20; left: 50%; bottom: 54px; max-width: calc(100% - 28px); padding: 7px 11px; overflow: hidden; border: 1px solid var(--border); border-radius: 999px; background: var(--foreground); color: var(--surface); box-shadow: var(--shadow-md); font-size: var(--text-xs); line-height: 16px; text-overflow: ellipsis; white-space: nowrap; pointer-events: none; transform: translateX(-50%); animation: popup-content-in .16s ease-out; }
|
||||
|
||||
.popup-loading { width: 390px; height: 230px; display: flex; align-items: center; justify-content: center; gap: 9px; color: var(--muted); background: var(--background); font-size: var(--text-md); }
|
||||
.spin { animation: spin .8s linear infinite; }
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
AlertTriangle, Braces, Check, Cookie, ExternalLink, Network, Radio, RefreshCw,
|
||||
ShieldCheck, UserRoundCog, X,
|
||||
AlertTriangle, Braces, Check, Cookie, ExternalLink, Gauge, Network, Radio, RefreshCw, UserRoundCog, X,
|
||||
} from 'lucide-react';
|
||||
import { browser } from 'wxt/browser';
|
||||
import { ProductBrand } from '@/components/brand/Brand';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { YakMark } from '@/components/brand/Brand';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tooltip, TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { HANDOFF_REASON_LABELS, waitingHandoff } from '@/features/handoff/presentation';
|
||||
import { READ_CAPABILITY_SCOPES } from '@/protocol/capabilities';
|
||||
import { isStateStorageChange } from '@/protocol/storage';
|
||||
import type { ActiveTabInfo, BridgeStatus, ExtensionState, ProxyProfile } from '@/types/models';
|
||||
import type { ActiveTabInfo, BridgeStatus, ExtensionState, UserAgentResolution } from '@/types/models';
|
||||
import { errorMessage, request } from '@/platform/messaging/runtime';
|
||||
import { CookieQuickView } from './views/CookieQuickView';
|
||||
import { OverviewQuickView } from './views/OverviewQuickView';
|
||||
import { ProxyQuickView } from './views/ProxyQuickView';
|
||||
import { UserAgentQuickView } from './views/UserAgentQuickView';
|
||||
import './App.css';
|
||||
|
||||
const PROXY_KIND_LABELS: Record<ProxyProfile['kind'], string> = {
|
||||
fixed_servers: '固定代理',
|
||||
pac_script: 'PAC Script',
|
||||
direct: '直连',
|
||||
system: '系统代理',
|
||||
type PopupView = 'home' | 'proxy' | 'cookies' | 'user-agent';
|
||||
|
||||
const FULL_VIEW_TARGETS: Record<PopupView, { section: string; label: string }> = {
|
||||
home: { section: 'overview', label: '打开完整工作台' },
|
||||
proxy: { section: 'rules', label: '打开代理策略' },
|
||||
cookies: { section: 'cookies', label: '打开完整 Cookie Editor' },
|
||||
'user-agent': { section: 'user-agent', label: '打开 User-Agent 管理' },
|
||||
};
|
||||
|
||||
function proxyDetail(profile: ProxyProfile): string {
|
||||
return profile.kind === 'fixed_servers'
|
||||
? `${profile.scheme}://${profile.host}:${profile.port}`
|
||||
: PROXY_KIND_LABELS[profile.kind];
|
||||
function engineStatusLabel(state: ExtensionState, bridge: BridgeStatus): string {
|
||||
if (bridge.state === 'connected') return '引擎在线';
|
||||
if (bridge.state === 'connecting') return '正在连接引擎';
|
||||
if (bridge.state === 'negotiating') return '正在验证引擎身份';
|
||||
if (bridge.state === 'error') return bridge.message || '引擎连接失败';
|
||||
return state.bridge.pairedEngine ? bridge.message || '引擎离线' : '尚未配对引擎';
|
||||
}
|
||||
|
||||
function App() {
|
||||
@@ -35,6 +39,9 @@ function App() {
|
||||
const [bridge, setBridge] = useState<BridgeStatus>({ state: 'disconnected', message: '未连接引擎' });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [view, setView] = useState<PopupView>('home');
|
||||
const [cookieCount, setCookieCount] = useState(0);
|
||||
const [uaResolution, setUaResolution] = useState<UserAgentResolution>();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [nextState, nextTab, nextBridge] = await Promise.all([
|
||||
@@ -45,6 +52,17 @@ function App() {
|
||||
setState(nextState);
|
||||
setTab(nextTab);
|
||||
setBridge(nextBridge);
|
||||
if (nextTab?.url?.startsWith('http')) {
|
||||
const [cookies, resolution] = await Promise.all([
|
||||
request('cookie.list', { url: nextTab.url }).catch(() => []),
|
||||
request('ua.resolve', { url: nextTab.url }).catch(() => undefined),
|
||||
]);
|
||||
setCookieCount(cookies.length);
|
||||
setUaResolution(resolution);
|
||||
} else {
|
||||
setCookieCount(0);
|
||||
setUaResolution(undefined);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -54,7 +72,7 @@ function App() {
|
||||
};
|
||||
browser.runtime.onMessage.addListener(listener);
|
||||
const onStorageChange = (changes: Record<string, unknown>) => {
|
||||
if (isStateStorageChange(changes)) void request('state.get').then(setState).catch(() => undefined);
|
||||
if (isStateStorageChange(changes)) void load();
|
||||
};
|
||||
browser.storage.onChanged.addListener(onStorageChange);
|
||||
return () => {
|
||||
@@ -63,14 +81,21 @@ function App() {
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!notice) return undefined;
|
||||
const timer = globalThis.setTimeout(() => setNotice(''), 2_400);
|
||||
return () => globalThis.clearTimeout(timer);
|
||||
}, [notice]);
|
||||
|
||||
const grantActive = Boolean(state?.activeGrant && state.activeGrant.expiresAt > Date.now() && tab && state.activeGrant.targets.some((target) => target.tabId === tab.id));
|
||||
const handoff = waitingHandoff(state?.handoff);
|
||||
|
||||
const run = async (task: () => Promise<void>) => {
|
||||
const run = async (task: () => Promise<void>, success?: string) => {
|
||||
setBusy(true);
|
||||
setNotice('');
|
||||
try {
|
||||
await task();
|
||||
if (success) setNotice(success);
|
||||
} catch (error) {
|
||||
setNotice(errorMessage(error));
|
||||
} finally {
|
||||
@@ -109,90 +134,79 @@ function App() {
|
||||
}
|
||||
|
||||
const engineBusy = bridge.state === 'connecting' || bridge.state === 'negotiating';
|
||||
const currentHost = (() => { try { return tab?.url ? new URL(tab.url).host : ''; } catch { return ''; } })();
|
||||
const statusLabel = engineStatusLabel(state, bridge);
|
||||
const statusActionLabel = bridge.state === 'connected'
|
||||
? `${statusLabel},点击断开`
|
||||
: state.bridge.pairedEngine ? `${statusLabel},点击连接` : `${statusLabel},点击配对`;
|
||||
const fullViewTarget = FULL_VIEW_TARGETS[view];
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={350}>
|
||||
<main className="popup-shell">
|
||||
<header className="popup-header">
|
||||
<div className="popup-brand-row">
|
||||
<ProductBrand compact />
|
||||
<div className="popup-header-main">
|
||||
<Tooltip label="Yakit Browser Agent" side="bottom">
|
||||
<span className="popup-brand-mark" role="img" aria-label="Yakit Browser Agent">
|
||||
<YakMark />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<div className="popup-target">
|
||||
<div className="popup-target-title">
|
||||
<span className="popup-favicon">{tab?.favIconUrl ? <img src={tab.favIconUrl} alt="" /> : <Radio size={12} />}</span>
|
||||
<strong title={tab?.title}>{tab?.title || '当前页面不可访问'}</strong>
|
||||
</div>
|
||||
<span className="popup-target-host" title={tab?.url}>{currentHost || '无法读取当前标签页'}</span>
|
||||
</div>
|
||||
<div className="popup-brand-actions">
|
||||
<Tooltip label={bridge.state === 'connected' ? '断开引擎连接' : state.bridge.pairedEngine ? '连接引擎' : '配对本机 Yakit'}>
|
||||
<button className={`popup-engine-pill ${bridge.state}`} disabled={busy} onClick={() => void toggleEngine()}>
|
||||
<i />{bridge.state === 'connected' ? '引擎在线' : engineBusy ? '连接中' : state.bridge.pairedEngine ? '引擎离线' : '配对'}
|
||||
<Tooltip label={statusActionLabel} side="bottom">
|
||||
<button className={`popup-engine-status ${bridge.state}`} aria-label={statusLabel} disabled={busy || engineBusy} onClick={() => void toggleEngine()}>
|
||||
<i aria-hidden="true" />
|
||||
<span>{bridge.state === 'connected' ? '在线' : engineBusy ? '连接中' : state.bridge.pairedEngine ? '离线' : '配对'}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="打开完整工作台">
|
||||
<Button size="icon" variant="ghost" aria-label="打开完整工作台" onClick={() => void openTool('overview')}>
|
||||
<Tooltip label={fullViewTarget.label} side="bottom">
|
||||
<Button size="icon" variant="ghost" aria-label={fullViewTarget.label} onClick={() => void openTool(fullViewTarget.section)}>
|
||||
<ExternalLink size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="popup-tab-line">
|
||||
<span className="popup-favicon">{tab?.favIconUrl ? <img src={tab.favIconUrl} alt="" /> : <Radio size={12} />}</span>
|
||||
<span title={tab?.url}>{tab?.title || '当前页面不可访问'}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{handoff && <section className="popup-handoff" aria-live="assertive">
|
||||
<AlertTriangle size={18} />
|
||||
<div className="popup-handoff__copy">
|
||||
<strong>{HANDOFF_REASON_LABELS[handoff.reason]}</strong>
|
||||
<span>{handoff.message}</span>
|
||||
<small title={handoff.target.title}>{handoff.target.title}</small>
|
||||
</div>
|
||||
<div className="popup-handoff__actions">
|
||||
<Button size="sm" variant="primary" disabled={busy} onClick={() => void run(async () => setState(await request('handoff.resolve', { id: handoff.id, outcome: 'completed' })))}><Check size={14} />完成</Button>
|
||||
<Button size="icon" variant="ghost" disabled={busy} aria-label="取消人工接管" title="取消人工接管" onClick={() => void run(async () => setState(await request('handoff.resolve', { id: handoff.id, outcome: 'cancelled' })))}><X size={15} /></Button>
|
||||
</div>
|
||||
</section>}
|
||||
|
||||
<section className={`popup-share ${grantActive ? 'is-active' : ''}`}>
|
||||
<div className="popup-share-copy">
|
||||
<ShieldCheck size={18} />
|
||||
<div>
|
||||
<strong>共享当前标签页</strong>
|
||||
<span>{grantActive ? `只读会话 ${new Date(state.activeGrant!.expiresAt).toLocaleTimeString()} 到期` : '创建 30 分钟只读会话'}</span>
|
||||
<div className="popup-body">
|
||||
<nav className="popup-rail" aria-label="Popup 工具导航">
|
||||
<div className="popup-rail-main">
|
||||
<Tooltip label="运行概览" side="right"><button className={view === 'home' ? 'is-active' : ''} aria-label="运行概览" aria-current={view === 'home' ? 'page' : undefined} onClick={() => setView('home')}><Gauge size={18} /></button></Tooltip>
|
||||
<Tooltip label="代理" side="right"><button className={view === 'proxy' ? 'is-active' : ''} aria-label="代理" aria-current={view === 'proxy' ? 'page' : undefined} onClick={() => setView('proxy')}><Network size={18} /></button></Tooltip>
|
||||
<Tooltip label="Cookie Editor" side="right"><button className={view === 'cookies' ? 'is-active' : ''} aria-label="Cookie Editor" aria-current={view === 'cookies' ? 'page' : undefined} onClick={() => setView('cookies')}><Cookie size={18} /></button></Tooltip>
|
||||
<Tooltip label="User-Agent" side="right"><button className={view === 'user-agent' ? 'is-active' : ''} aria-label="User-Agent" aria-current={view === 'user-agent' ? 'page' : undefined} onClick={() => setView('user-agent')}><UserRoundCog size={18} /></button></Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={grantActive} disabled={!tab || busy} aria-label="共享当前浏览器上下文" onCheckedChange={(checked) => void run(async () => {
|
||||
const updated = checked
|
||||
? await request('grant.create', { targets: [{ tabId: tab!.id, frameId: 0 }], scopes: READ_CAPABILITY_SCOPES, durationMinutes: 30 })
|
||||
: await request('grant.revoke');
|
||||
setState(updated);
|
||||
})} />
|
||||
</section>
|
||||
<div className="popup-rail-bottom">
|
||||
<Tooltip label="登录态工作区" side="right"><button aria-label="打开登录态工作区" onClick={() => void openTool('context')}><Braces size={18} /></button></Tooltip>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<section className="popup-proxy">
|
||||
<div className="popup-section-label"><Network size={14} /><span>当前代理</span>{state.activeProxyId === 'rules' && <Badge>规则分流</Badge>}</div>
|
||||
<div className="popup-proxy-list" role="radiogroup" aria-label="代理出口">
|
||||
{state.proxyProfiles.map((profile) => {
|
||||
const active = state.activeProxyId === profile.id;
|
||||
return <button key={profile.id} role="radio" aria-checked={active} className={active ? 'is-active' : ''} disabled={busy} onClick={() => void run(async () => setState(await request('proxy.switch', { id: profile.id })))}>
|
||||
<i className="popup-radio" />
|
||||
<span><strong>{profile.name}</strong><small>{proxyDetail(profile)}</small></span>
|
||||
</button>;
|
||||
})}
|
||||
{state.proxyRules.length > 0 && <button role="radio" aria-checked={state.activeProxyId === 'rules'} className={state.activeProxyId === 'rules' ? 'is-active' : ''} disabled={busy} onClick={() => void run(async () => setState(await request('proxy.rules.apply')))}>
|
||||
<i className="popup-radio" />
|
||||
<span><strong>按规则分流</strong><small>{state.proxyRules.filter((rule) => rule.enabled).length} 条启用规则</small></span>
|
||||
</button>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!handoff && <nav className="popup-tools" aria-label="安全测试工具">
|
||||
<button onClick={() => void openTool('cookies')}><Cookie size={17} /><span>Cookie</span></button>
|
||||
<button onClick={() => void openTool('user-agent')}><UserRoundCog size={17} /><span>User-Agent</span></button>
|
||||
<button onClick={() => void openTool('context')}><Braces size={17} /><span>登录态</span></button>
|
||||
</nav>}
|
||||
|
||||
<footer className="popup-footer">
|
||||
<Button className="popup-capture" variant="primary" disabled={busy || !tab?.url?.startsWith('http')} onClick={() => void capture()}>
|
||||
{busy ? <RefreshCw className="spin" size={15} /> : <Radio size={15} />}采集并复制上下文
|
||||
</Button>
|
||||
{notice && <span className="popup-notice">{notice}</span>}
|
||||
</footer>
|
||||
<section className="popup-workspace">
|
||||
{handoff && <section className="popup-handoff" aria-live="assertive">
|
||||
<AlertTriangle size={18} />
|
||||
<div className="popup-handoff__copy">
|
||||
<strong>{HANDOFF_REASON_LABELS[handoff.reason]}</strong>
|
||||
<span>{handoff.message}</span>
|
||||
<small title={handoff.target.title}>{handoff.target.title}</small>
|
||||
</div>
|
||||
<div className="popup-handoff__actions">
|
||||
<Button size="sm" variant="primary" disabled={busy} onClick={() => void run(async () => setState(await request('handoff.resolve', { id: handoff.id, outcome: 'completed' })))}><Check size={14} />完成</Button>
|
||||
<Button size="icon" variant="ghost" disabled={busy} aria-label="取消人工接管" title="取消人工接管" onClick={() => void run(async () => setState(await request('handoff.resolve', { id: handoff.id, outcome: 'cancelled' })))}><X size={15} /></Button>
|
||||
</div>
|
||||
</section>}
|
||||
{view === 'home' && <OverviewQuickView state={state} tab={tab} grantActive={grantActive} busy={busy} run={run} setState={setState} cookieCount={cookieCount} uaResolution={uaResolution} onNavigate={setView} onOpenContext={() => void openTool('context')} onCapture={() => void capture()} />}
|
||||
{view === 'proxy' && <ProxyQuickView state={state} setState={setState} busy={busy} run={run} tab={tab} onOpenFull={() => void openTool('rules')} />}
|
||||
{view === 'cookies' && <CookieQuickView tab={tab} busy={busy} run={run} onCountChange={setCookieCount} />}
|
||||
{view === 'user-agent' && <UserAgentQuickView tab={tab} state={state} setState={setState} busy={busy} run={run} onResolutionChange={setUaResolution} />}
|
||||
</section>
|
||||
</div>
|
||||
{notice && <span className="popup-global-notice" role="status">{notice}</span>}
|
||||
</main>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
html, body, #root { margin: 0; min-width: 390px; }
|
||||
body { overflow: hidden; }
|
||||
html, body, #root { width: 390px; height: 600px; margin: 0; overflow: hidden; }
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Check, Cookie, Copy, Plus, Search, Trash2, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cookieKey, cookieRemovalInput } from '@/features/cookies/presentation';
|
||||
import { request } from '@/platform/messaging/runtime';
|
||||
import type { ActiveTabInfo, BrowserCookie, CookieInput } from '@/types/models';
|
||||
|
||||
type RunTask = (task: () => Promise<void>, success?: string) => Promise<void>;
|
||||
|
||||
interface CookieQuickViewProps {
|
||||
tab?: ActiveTabInfo;
|
||||
busy: boolean;
|
||||
run: RunTask;
|
||||
onCountChange: (count: number) => void;
|
||||
}
|
||||
|
||||
function emptyDraft(url = ''): Omit<CookieInput, 'url'> {
|
||||
return {
|
||||
name: '', value: '', path: '/', secure: url.startsWith('https:'), httpOnly: false, sameSite: 'unspecified',
|
||||
};
|
||||
}
|
||||
|
||||
export function CookieQuickView({ tab, busy, run, onCountChange }: CookieQuickViewProps) {
|
||||
const url = tab?.url?.startsWith('http') ? tab.url : '';
|
||||
const [cookies, setCookies] = useState<BrowserCookie[]>([]);
|
||||
const [query, setQuery] = useState('');
|
||||
const [draft, setDraft] = useState<Omit<CookieInput, 'url'>>(emptyDraft(url));
|
||||
const [editing, setEditing] = useState<BrowserCookie>();
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!url) {
|
||||
setCookies([]);
|
||||
onCountChange(0);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const next = await request('cookie.list', { url });
|
||||
setCookies(next);
|
||||
onCountChange(next.length);
|
||||
setLoadError('');
|
||||
} catch (error) {
|
||||
setLoadError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}, [onCountChange, url]);
|
||||
|
||||
useEffect(() => { void reload(); }, [reload]);
|
||||
|
||||
const visibleCookies = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
return cookies.filter((cookie) => !needle || [cookie.name, cookie.domain, cookie.path]
|
||||
.some((value) => value.toLowerCase().includes(needle)));
|
||||
}, [cookies, query]);
|
||||
|
||||
const startNew = () => {
|
||||
setEditing(undefined);
|
||||
setDraft(emptyDraft(url));
|
||||
setEditorOpen(true);
|
||||
};
|
||||
const startEdit = (cookie: BrowserCookie) => {
|
||||
setEditing(cookie);
|
||||
setDraft({
|
||||
name: cookie.name, value: cookie.value, domain: cookie.hostOnly ? undefined : cookie.domain,
|
||||
path: cookie.path, secure: cookie.secure, httpOnly: cookie.httpOnly,
|
||||
sameSite: cookie.sameSite as CookieInput['sameSite'], expirationDate: cookie.expirationDate,
|
||||
storeId: cookie.storeId, firstPartyDomain: cookie.firstPartyDomain, partitionKey: cookie.partitionKey,
|
||||
});
|
||||
setEditorOpen(true);
|
||||
};
|
||||
const closeEditor = () => {
|
||||
setEditorOpen(false);
|
||||
setEditing(undefined);
|
||||
setDraft(emptyDraft(url));
|
||||
};
|
||||
|
||||
const saveCookie = () => run(async () => {
|
||||
if (!url || !draft.name) throw new Error('Cookie 名称不能为空');
|
||||
await request('cookie.set', { url, ...draft });
|
||||
await reload();
|
||||
closeEditor();
|
||||
}, editing ? 'Cookie 已更新' : 'Cookie 已创建');
|
||||
|
||||
return <section className="popup-view popup-tool-view popup-cookie-view">
|
||||
<div className="popup-tool-context"><Cookie size={14} /><span title={url}>{url ? new URL(url).host : '当前页面不可访问'}</span><strong>{cookies.length}</strong></div>
|
||||
|
||||
{editorOpen ? <div className="popup-cookie-editor popup-view-enter">
|
||||
<div className="popup-editor-title"><div><strong>{editing ? '编辑 Cookie' : '新增 Cookie'}</strong><span>{editing ? `${editing.domain}${editing.path}` : '默认创建 HostOnly Cookie'}</span></div><Button size="icon" variant="ghost" aria-label="关闭 Cookie 编辑器" onClick={closeEditor}><X size={15} /></Button></div>
|
||||
<label><span>名称</span><input autoFocus={!editing} disabled={Boolean(editing)} value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} placeholder="session_id" /></label>
|
||||
<label><span>值</span><input value={draft.value} onChange={(event) => setDraft({ ...draft, value: event.target.value })} /></label>
|
||||
<div className="popup-editor-grid"><label><span>Path</span><input disabled={Boolean(editing)} value={draft.path || '/'} onChange={(event) => setDraft({ ...draft, path: event.target.value || '/' })} /></label><label><span>SameSite</span><select value={draft.sameSite} onChange={(event) => setDraft({ ...draft, sameSite: event.target.value as CookieInput['sameSite'] })}><option value="unspecified">Unspecified</option><option value="lax">Lax</option><option value="strict">Strict</option><option value="no_restriction">None</option></select></label></div>
|
||||
<div className="popup-cookie-flags"><label><input type="checkbox" checked={draft.secure || false} onChange={(event) => setDraft({ ...draft, secure: event.target.checked })} />Secure</label><label><input type="checkbox" checked={draft.httpOnly || false} onChange={(event) => setDraft({ ...draft, httpOnly: event.target.checked })} />HttpOnly</label></div>
|
||||
{editing?.partitionKey && <div className="popup-inline-warning">Partitioned Cookie 将保留现有 top-level site;修改分区请打开完整编辑器。</div>}
|
||||
<Button variant="primary" disabled={busy || !url || !draft.name} onClick={() => void saveCookie()}><Check size={15} />{editing ? '保存修改' : '创建 Cookie'}</Button>
|
||||
</div> : <>
|
||||
<div className="popup-tool-toolbar"><label><Search size={14} /><input aria-label="搜索当前页面 Cookie" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索名称、Domain 或 Path" /></label><Button size="icon" variant="ghost" aria-label="新增 Cookie" title="新增 Cookie" disabled={!url} onClick={startNew}><Plus size={16} /></Button></div>
|
||||
<div className="popup-cookie-list popup-view-enter">
|
||||
{loadError && <div className="popup-tool-empty">{loadError}</div>}
|
||||
{!loadError && visibleCookies.length === 0 && <div className="popup-tool-empty">{cookies.length ? '没有匹配的 Cookie' : '当前页面没有可用 Cookie'}</div>}
|
||||
{visibleCookies.map((cookie) => {
|
||||
const key = cookieKey(cookie);
|
||||
const authRelated = /(auth|token|jwt|session|login|csrf|sid)/i.test(cookie.name);
|
||||
return <article className="popup-cookie-row" key={key}>
|
||||
<button className="popup-cookie-main" onClick={() => startEdit(cookie)}><span><strong>{cookie.name}</strong>{authRelated && <i>认证</i>}</span><code title={cookie.value}>{cookie.value}</code><small>{cookie.domain}{cookie.path}</small></button>
|
||||
<div className="popup-cookie-meta">{cookie.httpOnly && <i>HttpOnly</i>}{cookie.secure && <i>Secure</i>}{cookie.partitionKey && <i>CHIPS</i>}</div>
|
||||
<div className="popup-cookie-actions"><button aria-label={`复制 ${cookie.name}`} onClick={() => void run(async () => navigator.clipboard.writeText(`${cookie.name}=${cookie.value}`), 'Cookie 已复制')}><Copy size={14} /></button><button className="danger" aria-label={`删除 ${cookie.name}`} onClick={() => void run(async () => { await request('cookie.remove', cookieRemovalInput(cookie)); await reload(); }, 'Cookie 已删除')}><Trash2 size={14} /></button></div>
|
||||
</article>;
|
||||
})}
|
||||
</div>
|
||||
<div className="popup-tool-footer"><button disabled={!cookies.length || busy} onClick={() => { if (window.confirm(`删除当前页面可用的 ${cookies.length} 个 Cookie?`)) void run(async () => { await request('cookie.removeMany', { cookies: cookies.map(cookieRemovalInput) }); await reload(); }, '当前页面 Cookie 已清理'); }}><Trash2 size={14} />清理当前页面</button></div>
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Braces, ChevronRight, Cookie, Network, Radio, ShieldCheck, UserRoundCog } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { request } from '@/platform/messaging/runtime';
|
||||
import { READ_CAPABILITY_SCOPES } from '@/protocol/capabilities';
|
||||
import type { ActiveTabInfo, ExtensionState, UserAgentResolution } from '@/types/models';
|
||||
|
||||
type PopupView = 'home' | 'proxy' | 'cookies' | 'user-agent';
|
||||
type RunTask = (task: () => Promise<void>, success?: string) => Promise<void>;
|
||||
|
||||
interface OverviewQuickViewProps {
|
||||
state: ExtensionState;
|
||||
tab?: ActiveTabInfo;
|
||||
grantActive: boolean;
|
||||
busy: boolean;
|
||||
run: RunTask;
|
||||
setState: (state: ExtensionState) => void;
|
||||
cookieCount: number;
|
||||
uaResolution?: UserAgentResolution;
|
||||
onNavigate: (view: PopupView) => void;
|
||||
onOpenContext: () => void;
|
||||
onCapture: () => void;
|
||||
}
|
||||
|
||||
export function OverviewQuickView({
|
||||
state, tab, grantActive, busy, run, setState, cookieCount, uaResolution, onNavigate, onOpenContext, onCapture,
|
||||
}: OverviewQuickViewProps) {
|
||||
const activeProxy = state.activeProxyId === 'auto'
|
||||
? '自动切换'
|
||||
: state.proxyProfiles.find((profile) => profile.id === state.activeProxyId)?.name || '未选择';
|
||||
const targetAvailable = Boolean(tab?.url?.startsWith('http'));
|
||||
|
||||
return <section className="popup-overview-view">
|
||||
<section className={`popup-share ${grantActive ? 'is-active' : ''}`}>
|
||||
<div className="popup-share-copy">
|
||||
<ShieldCheck size={18} />
|
||||
<div>
|
||||
<strong>共享当前标签页</strong>
|
||||
<span>{grantActive ? `只读会话 ${new Date(state.activeGrant!.expiresAt).toLocaleTimeString()} 到期` : '创建 30 分钟只读会话'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={grantActive} disabled={!tab || busy} aria-label="共享当前浏览器上下文" onCheckedChange={(checked) => void run(async () => {
|
||||
const updated = checked
|
||||
? await request('grant.create', { targets: [{ tabId: tab!.id, frameId: 0 }], scopes: READ_CAPABILITY_SCOPES, durationMinutes: 30 })
|
||||
: await request('grant.revoke');
|
||||
setState(updated);
|
||||
})} />
|
||||
</section>
|
||||
|
||||
<div className="popup-overview-lead">
|
||||
<div className="popup-overview-lead__meta"><strong className={targetAvailable ? '' : 'is-unavailable'}><i />{targetAvailable ? '页面已就绪' : '页面不可访问'}</strong><span>{targetAvailable ? '从这里快速查看和调整当前标签页' : '切换到 HTTP(S) 页面后可使用浏览器工具'}</span></div>
|
||||
</div>
|
||||
|
||||
<section className="popup-overview-summary" aria-label="当前页面状态">
|
||||
<button onClick={() => onNavigate('proxy')}>
|
||||
<span className="popup-overview-icon"><Network size={16} /></span>
|
||||
<span><small>当前代理</small><strong>{activeProxy}</strong></span>
|
||||
<ChevronRight size={15} />
|
||||
</button>
|
||||
<button onClick={() => onNavigate('user-agent')}>
|
||||
<span className="popup-overview-icon"><UserRoundCog size={16} /></span>
|
||||
<span><small>当前 User-Agent</small><strong>{uaResolution?.profile?.name || '浏览器默认'}</strong></span>
|
||||
<ChevronRight size={15} />
|
||||
</button>
|
||||
<button onClick={() => onNavigate('cookies')}>
|
||||
<span className="popup-overview-icon"><Cookie size={16} /></span>
|
||||
<span><small>当前页面 Cookie</small><strong>{targetAvailable ? `${cookieCount} 个可用 Cookie` : '当前页面不可用'}</strong></span>
|
||||
<ChevronRight size={15} />
|
||||
</button>
|
||||
<button onClick={onOpenContext}>
|
||||
<span className="popup-overview-icon"><Braces size={16} /></span>
|
||||
<span><small>登录态工作区</small><strong>Storage、数据库与页面上下文</strong></span>
|
||||
<ChevronRight size={15} />
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<footer className="popup-footer">
|
||||
<Button className="popup-capture" variant="primary" disabled={busy || !targetAvailable} onClick={onCapture}>
|
||||
{busy ? <Radio className="spin" size={15} /> : <Radio size={15} />}采集并复制上下文
|
||||
</Button>
|
||||
</footer>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AlertCircle, Check, ExternalLink, Globe2, LoaderCircle, Network, Route } from 'lucide-react';
|
||||
import { request } from '@/platform/messaging/runtime';
|
||||
import type { ActiveTabInfo, ExtensionState, ProxyProfile, ProxyRulePreview } from '@/types/models';
|
||||
|
||||
type RunTask = (task: () => Promise<void>, success?: string) => Promise<void>;
|
||||
|
||||
interface ProxyQuickViewProps {
|
||||
state: ExtensionState;
|
||||
setState: (state: ExtensionState) => void;
|
||||
busy: boolean;
|
||||
run: RunTask;
|
||||
tab?: ActiveTabInfo;
|
||||
onOpenFull: () => void;
|
||||
}
|
||||
|
||||
const AUTOMATIC_TARGET = '__automatic__';
|
||||
const CURRENT_GLOBAL_TARGET = '__current_global__';
|
||||
|
||||
type SiteApplyStatus = 'idle' | 'applying' | 'success' | 'error';
|
||||
|
||||
const PROXY_KIND_LABELS: Record<ProxyProfile['kind'], string> = {
|
||||
fixed_servers: '固定代理',
|
||||
pac_script: 'PAC Script',
|
||||
direct: '直连',
|
||||
system: '系统代理',
|
||||
};
|
||||
|
||||
function proxyDetail(profile: ProxyProfile): string {
|
||||
return profile.kind === 'fixed_servers'
|
||||
? `${profile.scheme}://${profile.host}:${profile.port}`
|
||||
: PROXY_KIND_LABELS[profile.kind];
|
||||
}
|
||||
|
||||
function hostname(url?: string): string {
|
||||
try { return url ? new URL(url).hostname.toLowerCase() : ''; } catch { return ''; }
|
||||
}
|
||||
|
||||
function routeKindLabel(preview?: ProxyRulePreview): string {
|
||||
if (preview?.matchedKind === 'manual') return '站点覆盖';
|
||||
if (preview?.matchedKind === 'source') return '规则订阅';
|
||||
return '自动判断';
|
||||
}
|
||||
|
||||
export function ProxyQuickView({ state, setState, busy, run, tab, onOpenFull }: ProxyQuickViewProps) {
|
||||
const [preview, setPreview] = useState<ProxyRulePreview>();
|
||||
const currentHostname = hostname(tab?.url);
|
||||
const autoActive = state.activeProxyId === 'auto';
|
||||
const activeProfile = state.proxyProfiles.find((profile) => profile.id === state.activeProxyId);
|
||||
const routableProfiles = useMemo(
|
||||
() => state.proxyProfiles.filter((profile) => profile.kind === 'direct' || profile.kind === 'fixed_servers'),
|
||||
[state.proxyProfiles],
|
||||
);
|
||||
const siteRule = useMemo(() => [...state.proxyRules]
|
||||
.sort((left, right) => left.order - right.order)
|
||||
.find((rule) => rule.enabled && rule.condition.type === 'host_exact'
|
||||
&& rule.condition.value.toLowerCase() === currentHostname), [currentHostname, state.proxyRules]);
|
||||
const persistedTarget = siteRule?.proxyProfileId || AUTOMATIC_TARGET;
|
||||
const [siteTarget, setSiteTarget] = useState(autoActive ? persistedTarget : CURRENT_GLOBAL_TARGET);
|
||||
const [siteApplyStatus, setSiteApplyStatus] = useState<SiteApplyStatus>('idle');
|
||||
const [siteApplyMessage, setSiteApplyMessage] = useState('');
|
||||
const sourceRuleCount = state.proxyRuleSources
|
||||
.filter((source) => source.enabled && source.revision)
|
||||
.reduce((total, source) => total + source.supportedRuleCount, 0);
|
||||
|
||||
useEffect(() => {
|
||||
setSiteTarget(autoActive
|
||||
? routableProfiles.some((profile) => profile.id === persistedTarget) ? persistedTarget : AUTOMATIC_TARGET
|
||||
: CURRENT_GLOBAL_TARGET);
|
||||
}, [autoActive, persistedTarget, routableProfiles]);
|
||||
|
||||
useEffect(() => {
|
||||
setSiteApplyStatus('idle');
|
||||
setSiteApplyMessage('');
|
||||
}, [currentHostname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (siteApplyStatus !== 'success' && siteApplyStatus !== 'error') return undefined;
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
setSiteApplyStatus('idle');
|
||||
setSiteApplyMessage('');
|
||||
}, 2_400);
|
||||
return () => globalThis.clearTimeout(timer);
|
||||
}, [siteApplyStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tab?.url?.startsWith('http')) {
|
||||
setPreview(undefined);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void request('proxy.rules.preview', { url: tab.url })
|
||||
.then((result) => { if (!cancelled) setPreview(result); })
|
||||
.catch(() => { if (!cancelled) setPreview(undefined); });
|
||||
return () => { cancelled = true; };
|
||||
}, [tab?.url, state.proxyRuntime.revision, state.proxyRuntime.dirty]);
|
||||
|
||||
const switchAuto = () => run(async () => {
|
||||
setState(await request('proxy.auto.apply'));
|
||||
if (tab?.url) setPreview(await request('proxy.rules.preview', { url: tab.url }));
|
||||
}, '自动切换已启用');
|
||||
|
||||
const applySiteRoute = (nextTarget: string) => {
|
||||
if (!tab?.url || nextTarget === CURRENT_GLOBAL_TARGET) return Promise.resolve();
|
||||
const previousTarget = autoActive ? persistedTarget : CURRENT_GLOBAL_TARGET;
|
||||
const nextProfile = routableProfiles.find((profile) => profile.id === nextTarget);
|
||||
setSiteTarget(nextTarget);
|
||||
setSiteApplyStatus('applying');
|
||||
setSiteApplyMessage(nextTarget === AUTOMATIC_TARGET
|
||||
? '正在恢复自动判断…'
|
||||
: `正在切换到 ${nextProfile?.name || '所选出口'}…`);
|
||||
return run(async () => {
|
||||
try {
|
||||
const updated = nextTarget === AUTOMATIC_TARGET
|
||||
? await request('proxy.site.route.clear', { url: tab.url! })
|
||||
: await request('proxy.site.route', { url: tab.url!, profileId: nextTarget });
|
||||
setState(updated);
|
||||
setPreview(await request('proxy.rules.preview', { url: tab.url! }).catch(() => undefined));
|
||||
setSiteApplyStatus('success');
|
||||
setSiteApplyMessage(nextTarget === AUTOMATIC_TARGET
|
||||
? '已恢复自动判断'
|
||||
: `已应用 · ${nextProfile?.name || '所选出口'}`);
|
||||
} catch (error) {
|
||||
setSiteTarget(previousTarget);
|
||||
setSiteApplyStatus('error');
|
||||
setSiteApplyMessage('切换失败,已恢复原设置');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const effectiveProfile = state.proxyProfiles.find((profile) => profile.id === preview?.effectiveProfileId);
|
||||
const activeModeName = autoActive ? '自动切换' : activeProfile?.name || '未选择';
|
||||
const siteHint = !autoActive
|
||||
? `当前使用“${activeModeName}”;选择网站出口后将启用自动切换。`
|
||||
: siteTarget === AUTOMATIC_TARGET
|
||||
? '不创建手动覆盖,由订阅源和默认出口决定。'
|
||||
: `最高优先级的精确主机规则,只影响 ${currentHostname}。`;
|
||||
const routeLabel = autoActive ? preview?.matchedName || '正在解析路由' : '当前全局模式';
|
||||
const routeProfile = autoActive ? effectiveProfile : activeProfile;
|
||||
const routeKind = autoActive ? preview?.matchedKind || 'default' : 'global';
|
||||
const routeKindText = autoActive ? routeKindLabel(preview) : '全局模式';
|
||||
|
||||
return <section className="popup-view popup-tool-view popup-proxy-view">
|
||||
{currentHostname ? <section className="popup-site-router" aria-label="当前站点路由">
|
||||
<div className="popup-site-router__heading">
|
||||
<div><Globe2 size={16} /><span><small>当前站点</small><strong title={currentHostname}>{currentHostname}</strong></span></div>
|
||||
<i className={routeKind}>{routeKindText}</i>
|
||||
</div>
|
||||
<div className="popup-site-decision" title={autoActive ? preview?.matchedCondition : activeModeName}>
|
||||
<span>{routeLabel}</span><i>→</i><strong>{routeProfile?.name || '—'}</strong>
|
||||
</div>
|
||||
<div className="popup-site-picker">
|
||||
<label htmlFor="popup-site-proxy">网站出口 <span>选择后立即生效</span></label>
|
||||
<select id="popup-site-proxy" aria-label="当前站点代理出口" value={siteTarget} disabled={busy} aria-busy={siteApplyStatus === 'applying'} onChange={(event) => void applySiteRoute(event.target.value)}>
|
||||
{!autoActive && <option value={CURRENT_GLOBAL_TARGET}>当前全局模式 · {activeModeName}</option>}
|
||||
<option value={AUTOMATIC_TARGET}>跟随自动规则 · 清除站点覆盖</option>
|
||||
{routableProfiles.map((profile) => <option value={profile.id} key={profile.id}>{profile.name} · {proxyDetail(profile)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className={`popup-site-status is-${siteApplyStatus}`} role="status" aria-live="polite">
|
||||
{siteApplyStatus === 'applying' && <LoaderCircle size={13} className="spin" />}
|
||||
{siteApplyStatus === 'success' && <Check size={13} />}
|
||||
{siteApplyStatus === 'error' && <AlertCircle size={13} />}
|
||||
<small>{siteApplyMessage || siteHint}</small>
|
||||
</div>
|
||||
</section> : <div className="popup-proxy-unavailable"><Globe2 size={17} /><span><strong>当前页面无法设置站点路由</strong><small>请切换到 HTTP(S) 页面。</small></span></div>}
|
||||
|
||||
<div className="popup-mode-heading"><span><strong>浏览器模式</strong><small>全局切换,不会创建站点规则</small></span><i>{activeModeName}</i></div>
|
||||
<div className="popup-proxy-list popup-proxy-list--view" role="radiogroup" aria-label="浏览器代理模式">
|
||||
<button role="radio" aria-checked={autoActive} className={autoActive ? 'is-active' : ''} disabled={busy} onClick={() => void switchAuto()}>
|
||||
<span className="popup-mode-icon"><Route size={15} /></span>
|
||||
<span><strong>自动切换</strong><small>{state.proxyRules.filter((rule) => rule.enabled).length} 条手动 · {sourceRuleCount.toLocaleString()} 条订阅</small></span>
|
||||
{state.proxyRuntime.dirty ? <em>待应用</em> : autoActive ? <Check size={14} /> : null}
|
||||
</button>
|
||||
{state.proxyProfiles.map((profile) => {
|
||||
const active = state.activeProxyId === profile.id;
|
||||
return <button key={profile.id} role="radio" aria-checked={active} className={active ? 'is-active' : ''} disabled={busy} onClick={() => void run(async () => setState(await request('proxy.switch', { id: profile.id })), `${profile.name} 已作为全局模式启用`)}>
|
||||
<span className="popup-mode-icon"><Network size={15} /></span>
|
||||
<span><strong>{profile.name}</strong><small>{proxyDetail(profile)}</small></span>
|
||||
{active && <Check size={14} />}
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
<div className="popup-tool-footer"><span>{state.proxyProfiles.length} 个出口 · {state.proxyRuleSources.length} 个订阅</span><button onClick={onOpenFull}><ExternalLink size={13} />管理策略</button></div>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Bot, Laptop, RefreshCw, Save, Smartphone, UserRoundCog, X } from 'lucide-react';
|
||||
import { browser } from 'wxt/browser';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { request } from '@/platform/messaging/runtime';
|
||||
import type {
|
||||
ActiveTabInfo, ExtensionState, UserAgentProfile, UserAgentProfileCategory, UserAgentResolution,
|
||||
} from '@/types/models';
|
||||
|
||||
const BROWSER_DEFAULT = '__browser_default__';
|
||||
type RunTask = (task: () => Promise<void>, success?: string) => Promise<void>;
|
||||
|
||||
interface UserAgentQuickViewProps {
|
||||
tab?: ActiveTabInfo;
|
||||
state: ExtensionState;
|
||||
setState: (state: ExtensionState) => void;
|
||||
busy: boolean;
|
||||
run: RunTask;
|
||||
onResolutionChange: (resolution?: UserAgentResolution) => void;
|
||||
}
|
||||
|
||||
function categoryIcon(category: UserAgentProfileCategory) {
|
||||
if (category === 'mobile') return <Smartphone size={15} />;
|
||||
if (category === 'bot') return <Bot size={15} />;
|
||||
if (category === 'custom') return <UserRoundCog size={15} />;
|
||||
return <Laptop size={15} />;
|
||||
}
|
||||
|
||||
export function UserAgentQuickView({ tab, state, setState, busy, run, onResolutionChange }: UserAgentQuickViewProps) {
|
||||
const url = tab?.url?.startsWith('http') ? tab.url : '';
|
||||
const [profiles, setProfiles] = useState<UserAgentProfile[]>([]);
|
||||
const [resolution, setResolution] = useState<UserAgentResolution>();
|
||||
const [selectedProfileId, setSelectedProfileId] = useState(BROWSER_DEFAULT);
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const [customName, setCustomName] = useState('');
|
||||
const [customValue, setCustomValue] = useState('');
|
||||
const [loadError, setLoadError] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!url) {
|
||||
setProfiles(await request('ua.catalog'));
|
||||
setResolution(undefined);
|
||||
onResolutionChange(undefined);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [nextProfiles, nextResolution] = await Promise.all([request('ua.catalog'), request('ua.resolve', { url })]);
|
||||
setProfiles(nextProfiles);
|
||||
setResolution(nextResolution);
|
||||
setSelectedProfileId(nextResolution.profile?.id || BROWSER_DEFAULT);
|
||||
onResolutionChange(nextResolution);
|
||||
setLoadError('');
|
||||
} catch (error) {
|
||||
setLoadError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}, [onResolutionChange, url]);
|
||||
useEffect(() => { void load(); }, [load, state.customUserAgentProfiles, state.userAgentAssignments]);
|
||||
|
||||
const applyAndReload = () => run(async () => {
|
||||
if (!tab || !url) throw new Error('当前页面不能修改 User-Agent');
|
||||
const next = selectedProfileId === BROWSER_DEFAULT
|
||||
? await request('ua.site.reset', { url })
|
||||
: await request('ua.site.apply', { url, profileId: selectedProfileId });
|
||||
setState(next);
|
||||
const resolved = await request('ua.resolve', { url });
|
||||
setResolution(resolved);
|
||||
onResolutionChange(resolved);
|
||||
await browser.tabs.reload(tab.id);
|
||||
}, selectedProfileId === BROWSER_DEFAULT ? '已恢复浏览器默认 UA 并刷新页面' : 'User-Agent 已应用并刷新页面');
|
||||
|
||||
const saveCustomAndApply = () => run(async () => {
|
||||
if (!tab || !url) throw new Error('当前页面不能修改 User-Agent');
|
||||
const profile = await request('ua.profile.save', { name: customName, userAgent: customValue });
|
||||
const next = await request('ua.site.apply', { url, profileId: profile.id });
|
||||
setState(next);
|
||||
setProfiles(await request('ua.catalog'));
|
||||
setSelectedProfileId(profile.id);
|
||||
const resolved = await request('ua.resolve', { url });
|
||||
setResolution(resolved);
|
||||
onResolutionChange(resolved);
|
||||
setCustomOpen(false);
|
||||
setCustomName('');
|
||||
setCustomValue('');
|
||||
await browser.tabs.reload(tab.id);
|
||||
}, '自定义 User-Agent 已保存、应用并刷新页面');
|
||||
|
||||
return <section className="popup-view popup-tool-view popup-ua-view">
|
||||
<div className="popup-tool-context"><UserRoundCog size={14} /><span>{resolution?.hostname || (url ? new URL(url).hostname : '当前页面不可访问')}</span><strong>{resolution?.mode === 'override' ? '已覆盖' : '默认'}</strong></div>
|
||||
<div className="popup-ua-current"><span>当前生效</span><strong>{resolution?.profile?.name || '浏览器默认'}</strong><code title={resolution?.userAgent}>{resolution?.userAgent || navigator.userAgent}</code><small>仅修改网络请求头,不等于完整设备指纹伪装。</small></div>
|
||||
{customOpen ? <div className="popup-ua-custom popup-view-enter"><div className="popup-editor-title"><div><strong>自定义 User-Agent</strong><span>保存为预设并应用到当前 hostname</span></div><Button size="icon" variant="ghost" aria-label="关闭自定义 UA" onClick={() => setCustomOpen(false)}><X size={15} /></Button></div><label><span>预设名称</span><input autoFocus value={customName} onChange={(event) => setCustomName(event.target.value)} placeholder="API Client" /></label><label><span>User-Agent</span><textarea rows={5} value={customValue} onChange={(event) => setCustomValue(event.target.value)} placeholder="Custom-Agent/1.0" /></label><Button variant="primary" disabled={busy || !customName.trim() || !customValue.trim()} onClick={() => void saveCustomAndApply()}><Save size={15} />保存、应用并刷新</Button></div> : <>
|
||||
<div className="popup-ua-list popup-view-enter" role="radiogroup" aria-label="User-Agent 预设">
|
||||
<button role="radio" aria-checked={selectedProfileId === BROWSER_DEFAULT} className={selectedProfileId === BROWSER_DEFAULT ? 'is-selected' : ''} onClick={() => setSelectedProfileId(BROWSER_DEFAULT)}><span className="popup-ua-icon"><RefreshCw size={15} /></span><span><strong>浏览器默认</strong><small>移除当前站点覆盖</small></span><i /></button>
|
||||
{profiles.map((profile) => <button role="radio" aria-checked={selectedProfileId === profile.id} className={selectedProfileId === profile.id ? 'is-selected' : ''} key={profile.id} onClick={() => setSelectedProfileId(profile.id)}><span className="popup-ua-icon">{categoryIcon(profile.category)}</span><span><strong>{profile.name}</strong><small>{profile.builtin ? profile.category === 'mobile' ? '移动设备模板' : profile.category === 'bot' ? '爬虫模板' : '桌面设备模板' : '自定义预设'}</small></span><i /></button>)}
|
||||
</div>
|
||||
{loadError && <div className="popup-inline-warning">{loadError}</div>}
|
||||
<div className="popup-ua-actions"><Button variant="ghost" disabled={!url || busy} onClick={() => setCustomOpen(true)}>自定义…</Button><Button variant="primary" disabled={!url || busy || selectedProfileId === (resolution?.profile?.id || BROWSER_DEFAULT)} onClick={() => void applyAndReload()}><RefreshCw size={15} />应用并刷新</Button></div>
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
Reference in New Issue
Block a user