Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 33 additions & 13 deletions mihomo-control/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,29 @@ secret.
| Field | Value |
| ------- | --------------------------- |
| ID | `mdj2812/mihomo-control` |
| Entries | Bar widget: `widget`; panel: `panel`; service: `service`; shortcut: `mode` |
| Entries | Bar widget: `widget`; panels: `panel`, `panel-attached`; service: `service`; shortcut: `mode` |

## Usage

Add the **Mihomo Control** widget from the Add-widget picker. It shows the
current proxy mode (rule / global / direct); hover it for the connection
status, live download and upload rates, connection count and each proxy
group's current selection. Left-click the widget to toggle the control panel,
or open it with:
Add the **Mihomo Control** widget from the Add-widget picker. By default it
shows the current proxy mode (rule / global / direct). **Widget label** can
instead show nothing, download speed, upload speed, both rates, or the active
connection count. It can also show the first visible proxy group's current
selection or its selected latency, falling back to that group's best known
member latency. Its size and color presentation are also configurable. Hover it
for the connection status, live download and upload rates, connection count and
each proxy group's current selection. Left-click the widget to toggle the
control panel using the configured **Panel placement**. Both variants can also
be opened directly:

```sh
noctalia msg panel-toggle mdj2812/mihomo-control:panel
noctalia msg panel-toggle mdj2812/mihomo-control:panel-attached
```

The first command opens the centered floating panel; the second opens the
variant attached to the bar.

The panel lets you switch the proxy mode (rule / global / direct), restart the
server, refresh the status, and manage every proxy group. Each group card
lists all of its members with their latency — sourced from mihomo's health
Expand All @@ -33,7 +42,10 @@ shows an overall latency (the selected member's, or the best tested one) right
in its subtitle, visible without expanding. Member lists are collapsed by
default; click a group header to expand it. Click a member to select it; the
current selection is marked with a dot. Use **Test all** next to the Proxy
groups heading to run a latency test on every group at once.
groups heading to run a latency test on every group at once. Drag a card by
its ☰ grip over another card to reorder the groups. The nearest insertion gap
opens to preview the resulting layout before drop. The custom display order
survives controller polling and plugin restarts.

Add the **Mihomo: Rule** shortcut from Settings → Control Center shortcuts to
quickly toggle between rule and global mode.
Expand All @@ -52,6 +64,11 @@ all communication with the external controller and streams the traffic data.
| Allow insecure TLS | bool | off | Skip certificate verification for self-signed TLS controllers. |
| Test URL | string | `https://www.gstatic.com/generate_204` | URL used for latency tests; empty uses each group's configured test URL. |
| Refresh interval | int | `2` | Seconds between status polls (1–60); traffic rates stream in real time. |
| Panel placement | select | `floating` | Open the control panel centered on screen or attached to the bar widget. |
| Widget label | select | `mode` | Show nothing, mode, traffic, connections, or first-group proxy/latency. |
| Icon size | int | `16` | Bar icon size in pixels (10–32). |
| Icon color mode | select | `status` | Color the Mihomo glyph by connection status or use a custom color. |
| Icon color | color | `primary` | Color used by the tintable Mihomo glyph. |

## IPC

Expand All @@ -64,16 +81,18 @@ noctalia msg plugin mdj2812/mihomo-control:service all cmd '{"op":"mode","mode":

`refresh` re-polls version, config, connections and proxy groups. `cmd` accepts
the same command tables the panel sends (`mode`, `select`, `delay_test`,
`delay_test_all`, `restart`, `close_connections`, `refresh`).
`delay_test_all`, `reorder_group`, `restart`, `close_connections`, `refresh`).

## Notes

- The plugin only talks HTTP to the configured external controller. It spawns
no processes, runs no external commands, and writes no files.
- The bar widget and card use the Clash cat logo (`icon.png`), the official
mascot of the Clash / mihomo project. In the bar widget the cat is tinted by
connection status: green online, amber while connecting, red offline; the
panel uses the neutral logo next to its own status indicator.
no processes and runs no external commands. It writes only the custom
proxy-group display order (group names, never the secret) to its Noctalia
plugin data directory. Reordering does not modify the Mihomo configuration.
- The bar widget uses a tintable glyph traced from the Clash cat silhouette. By
default it is green online, amber while connecting and red offline; **Icon
color mode** can instead apply one custom color. The panel keeps the official
neutral logo next to its own status indicator.
- The traffic rate uses mihomo's streaming `GET /traffic` endpoint; status,
connections and proxy groups are polled every refresh interval.
- The secret is stored in your Noctalia config and sent as the standard
Expand All @@ -92,4 +111,5 @@ the same command tables the panel sends (`mode`, `select`, `delay_test`,
- `widget.luau` — bar widget (rates + tooltip).
- `panel.luau` — control panel.
- `shortcut.luau` — rule/global mode toggle.
- `fonts/` — reviewable vector source and generated tintable Mihomo glyph font.
- `translations/` — user-facing strings.
2 changes: 2 additions & 0 deletions mihomo-control/fonts/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__pycache__/
*.py[cod]
14 changes: 14 additions & 0 deletions mihomo-control/fonts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Mihomo Glyph

`mihomo-glyph.ttf` contains one private-use glyph at `U+E001`, traced from the
bundled Mihomo cat image. It preserves the recognizable silhouette, eyes, mouth,
ears, and tail while allowing Noctalia to color it like text.

The accompanying SVG is the reviewable vector source. Regenerate both assets
with:

```sh
python3 fonts/generate_mihomo_glyph.py
```

Generation requires OpenCV and fontTools; neither is needed at runtime.
155 changes: 155 additions & 0 deletions mihomo-control/fonts/generate_mihomo_glyph.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Build a tintable icon font from the bundled Mihomo logo.

Development dependencies: OpenCV and fontTools. The generated TTF has one
private-use glyph at U+E001. It is loaded by the widget at runtime; users do
not need either dependency.
"""

from __future__ import annotations

from pathlib import Path

import cv2
from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen


ROOT = Path(__file__).resolve().parent
SOURCE = ROOT.parent / "icon.png"
SVG_OUTPUT = ROOT / "mihomo-glyph.svg"
FONT_OUTPUT = ROOT / "mihomo-glyph.ttf"
UNITS_PER_EM = 1000
GLYPH_CODEPOINT = 0xE001


def signed_area(points: list[tuple[int, int]]) -> float:
return sum(
x1 * y2 - x2 * y1
for (x1, y1), (x2, y2) in zip(points, points[1:] + points[:1])
) / 2


def load_contours() -> list[tuple[list[tuple[int, int]], bool]]:
image = cv2.imread(str(SOURCE), cv2.IMREAD_UNCHANGED)
if image is None or image.shape[2] != 4:
raise SystemExit(f"Could not read RGBA source image: {SOURCE}")

blue, green, red, alpha = cv2.split(image)
white = (red > 220) & (green > 220) & (blue > 220)
mask = ((alpha > 16) & ~white).astype("uint8") * 255
contours, hierarchy = cv2.findContours(mask, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_NONE)
if hierarchy is None:
raise SystemExit("No contours found in Mihomo source image")

x, y, width, height = cv2.boundingRect(mask)
scale = min(840 / width, 840 / height)
x_offset = (UNITS_PER_EM - width * scale) / 2
y_offset = 80

result: list[tuple[list[tuple[int, int]], bool]] = []
for index, contour in enumerate(contours):
if abs(cv2.contourArea(contour)) < 2:
continue
simplified = cv2.approxPolyDP(contour, 0.75, True)
points = [
(
round((int(point[0][0]) - x) * scale + x_offset),
round((y + height - int(point[0][1])) * scale + y_offset),
)
for point in simplified
]
if len(points) < 3:
continue

is_hole = hierarchy[0][index][3] != -1
area = signed_area(points)
should_be_clockwise = not is_hole
if (should_be_clockwise and area > 0) or (is_hole and area < 0):
points.reverse()
result.append((points, is_hole))

if not result:
raise SystemExit("No usable contours found in Mihomo source image")
return result


def build_glyph(contours: list[tuple[list[tuple[int, int]], bool]]):
pen = TTGlyphPen(None)
for points, _is_hole in contours:
pen.moveTo(points[0])
for point in points[1:]:
pen.lineTo(point)
pen.closePath()
return pen.glyph()


def write_svg(contours: list[tuple[list[tuple[int, int]], bool]]) -> None:
paths = []
for points, _is_hole in contours:
commands = [f"M {points[0][0]} {UNITS_PER_EM - points[0][1]}"]
commands.extend(f"L {x} {UNITS_PER_EM - y}" for x, y in points[1:])
commands.append("Z")
paths.append(" ".join(commands))
SVG_OUTPUT.write_text(
"\n".join(
[
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">',
f' <path d="{" ".join(paths)}" fill="#000" fill-rule="nonzero"/>',
"</svg>",
"",
]
),
encoding="utf-8",
)


def write_font(contours: list[tuple[list[tuple[int, int]], bool]]) -> None:
empty_pen = TTGlyphPen(None)
glyphs = {
".notdef": empty_pen.glyph(),
"space": TTGlyphPen(None).glyph(),
"mihomo": build_glyph(contours),
}

builder = FontBuilder(UNITS_PER_EM, isTTF=True)
builder.setupGlyphOrder([".notdef", "space", "mihomo"])
builder.setupCharacterMap({32: "space", GLYPH_CODEPOINT: "mihomo"})
builder.setupGlyf(glyphs)
builder.setupHorizontalMetrics({
".notdef": (UNITS_PER_EM, 0),
"space": (500, 0),
"mihomo": (UNITS_PER_EM, 0),
})
builder.setupHorizontalHeader(ascent=1000, descent=-200)
builder.setupNameTable({
"familyName": "Mihomo Glyph",
"styleName": "Regular",
"uniqueFontIdentifier": "Mihomo Glyph Regular 1.0",
"fullName": "Mihomo Glyph Regular",
"psName": "MihomoGlyph-Regular",
"version": "Version 1.0",
})
builder.setupOS2(
sTypoAscender=1000,
sTypoDescender=-200,
usWinAscent=1000,
usWinDescent=200,
)
builder.setupPost()
builder.setupMaxp()
builder.font["head"].created = 2082844800
builder.font["head"].modified = 2082844800
builder.font.recalcTimestamp = False
builder.save(FONT_OUTPUT)


def main() -> None:
contours = load_contours()
write_svg(contours)
write_font(contours)
print(FONT_OUTPUT)


if __name__ == "__main__":
main()
3 changes: 3 additions & 0 deletions mihomo-control/fonts/mihomo-glyph.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added mihomo-control/fonts/mihomo-glyph.ttf
Binary file not shown.
Binary file removed mihomo-control/icon-connecting.png
Binary file not shown.
Binary file removed mihomo-control/icon-offline.png
Binary file not shown.
Binary file removed mihomo-control/icon-online.png
Binary file not shown.
60 changes: 58 additions & 2 deletions mihomo-control/panel.luau
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
-- and a latency-test button.

local MODES = { "rule", "global", "direct" }
local GROUP_DRAG_TYPE = "mihomo-proxy-group"
local GROUP_DROP_HIT_SLOP = 128

local snapshot = {
connection = {},
Expand Down Expand Up @@ -292,6 +294,23 @@ local function traffic_card()
})
end

-- Thin insertion target before every group card, plus one after the final
-- card. Its drag-only halo covers the neighboring card body; overlapping
-- targets resolve to the closest insertion point. expandOnDrag then opens a
-- full-card gap, previewing the resulting layout before the pointer is released.
local function group_insertion_zone(index)
return ui.dropZone({
key = "group-gap-" .. index,
accepts = { GROUP_DRAG_TYPE },
value = tostring(index),
onDrop = "onGroupDropped",
height = 3,
radius = 6,
expandOnDrag = true,
hitSlop = GROUP_DROP_HIT_SLOP,
})
end

local function group_card(group)
local name_text = group.name
if group.hidden == true then
Expand All @@ -317,6 +336,22 @@ local function group_card(group)
ui.row({ gap = 6, align = "center", justify = "space_between", onClick = function()
toggle_group(group.name)
end }, {
-- Lift the complete slot, including its insertion zone, so no selector
-- line remains at the source position while the card is being dragged.
ui.dragSource({
key = "group-grip-" .. group.name,
dragType = GROUP_DRAG_TYPE,
payload = group.name,
previewAncestor = 3,
liftFromLayout = true,
width = 20,
height = 20,
align = "center",
justify = "center",
tooltip = noctalia.tr("panel.drag_group"),
}, {
ui.glyph({ name = "menu-2", size = 14, color = "on_surface_variant" }),
}),
ui.row({ gap = 4, align = "center", flexGrow = 1 }, name_children),
ui.button({
glyph = "activity",
Expand Down Expand Up @@ -381,6 +416,7 @@ local function group_card(group)
end

return ui.column({
key = "group-card-" .. group.name,
gap = 8,
border = "outline",
borderWidth = 1,
Expand All @@ -390,6 +426,17 @@ local function group_card(group)
}, children)
end

local function group_slot(group, index)
return ui.column({
key = "group-slot-" .. group.name,
gap = 3,
flexGrow = 1,
}, {
group_insertion_zone(index),
group_card(group),
})
end

render = function()
local groups = type(snapshot.groups) == "table" and snapshot.groups or {}

Expand Down Expand Up @@ -417,14 +464,15 @@ render = function()
}),
}
for i = 1, #groups, 2 do
local row_children = { group_card(groups[i]) }
local row_children = { group_slot(groups[i], i) }
if groups[i + 1] then
table.insert(row_children, group_card(groups[i + 1]))
table.insert(row_children, group_slot(groups[i + 1], i + 1))
else
table.insert(row_children, ui.box({ flexGrow = 1 }))
end
table.insert(group_children, ui.row({ gap = 10, align = "start" }, row_children))
end
table.insert(group_children, group_insertion_zone(#groups + 1))
table.insert(body, ui.column({ gap = 10, fill = "surface/0.5", radius = 12, padding = 12 }, group_children))
else
table.insert(body, ui.column({ gap = 10, fill = "surface/0.5", radius = 12, padding = 12 }, {
Expand Down Expand Up @@ -460,6 +508,14 @@ render = function()
}))
end

function onGroupDropped(payload, value)
local insertion_index = tonumber(value)
if type(payload) ~= "string" or payload == "" or insertion_index == nil then
return
end
send_command({ op = "reorder_group", group = payload, index = insertion_index })
end

for _, key in {
"mihomo.connection",
"mihomo.config",
Expand Down
Loading
Loading