tr]:last:border-b-0",
46 | className
47 | )}
48 | {...props}
49 | />
50 | )
51 | }
52 |
53 | function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
54 | return (
55 |
63 | )
64 | }
65 |
66 | function TableHead({ className, ...props }: React.ComponentProps<"th">) {
67 | return (
68 | [role=checkbox]]:translate-y-[2px]",
72 | className
73 | )}
74 | {...props}
75 | />
76 | )
77 | }
78 |
79 | function TableCell({ className, ...props }: React.ComponentProps<"td">) {
80 | return (
81 | | [role=checkbox]]:translate-y-[2px]",
85 | className
86 | )}
87 | {...props}
88 | />
89 | )
90 | }
91 |
92 | function TableCaption({
93 | className,
94 | ...props
95 | }: React.ComponentProps<"caption">) {
96 | return (
97 |
102 | )
103 | }
104 |
105 | export {
106 | Table,
107 | TableHeader,
108 | TableBody,
109 | TableFooter,
110 | TableHead,
111 | TableRow,
112 | TableCell,
113 | TableCaption,
114 | }
115 |
--------------------------------------------------------------------------------
/gui/frontend/src/views/Log.tsx:
--------------------------------------------------------------------------------
1 | import {Trash2} from "lucide-react";
2 | import {ScrollArea, ScrollBar} from "@/components/ui/scroll-area.tsx";
3 | import {Light as SyntaxHighlighter} from "react-syntax-highlighter";
4 | import {atomOneDark, atomOneLight} from "react-syntax-highlighter/dist/esm/styles/hljs"
5 | import {ComponentProps, useEffect, useMemo, useRef, useState} from "react";
6 | import {useTheme} from "@/components/theme-provider";
7 | import {EventsOff, EventsOn} from "../../wailsjs/runtime";
8 | import {ConnectStatus} from "@/views/types.ts";
9 | import {cn} from "@/lib/utils.ts";
10 | import cliLog from '@/lib/golog.js';
11 |
12 | SyntaxHighlighter.registerLanguage('accesslog', cliLog);
13 |
14 | interface LogViewProps {
15 | status: ConnectStatus
16 | }
17 |
18 | export default function LogView({status, className, ...props}: ComponentProps<'div'> & LogViewProps) {
19 | const [logValue, setLogValue] = useState('');
20 | const logCount = useRef(0);
21 | const {theme} = useTheme()
22 |
23 | // 改一下默认的 bg 样式
24 | const highlighterStyle = useMemo(() => {
25 | const baseTheme = theme === 'light' ? atomOneLight : atomOneDark;
26 | const mainStyleKey = 'hljs';
27 | return {
28 | ...baseTheme,
29 | [mainStyleKey]: {
30 | ...(baseTheme[mainStyleKey] || {}),
31 | background: 'inherit', // 或者 'transparent'
32 | padding: 0,
33 | },
34 | };
35 | }, [theme]);
36 |
37 | // logValue 改变时,自动滚动到底部
38 | const scrollEndRef = useRef(null);
39 | useEffect(() => {
40 | if (scrollEndRef.current) {
41 | scrollEndRef.current.scrollIntoView({behavior: 'auto', block: 'end'});
42 | }
43 | }, [logValue]);
44 |
45 |
46 | const onLogValue = (e: string) => {
47 | // 防止日志太多 OOM
48 | logCount.current += 1
49 | if (logCount.current == 1000) {
50 | logCount.current = 0
51 | setLogValue('')
52 | }
53 | setLogValue((prev) => `${prev}${e}`)
54 | }
55 |
56 | useEffect(() => {
57 | EventsOn('log', onLogValue)
58 | return () => {
59 | EventsOff('log')
60 | }
61 | }, []);
62 |
63 | useEffect(() => {
64 | if (status === ConnectStatus.CONNECTING) {
65 | setLogValue('')
66 | }
67 | }, [status]);
68 |
69 | const baseClass = "flex flex-1 flex-col space-y-4 bg-muted border border-border/50 rounded [&>p]:m-0 [&>p]:text-sm min-h-0"
70 |
71 | return (
72 |
74 |
75 | 运行日志
76 | setLogValue('')}/>
77 |
78 |
79 |
85 |
86 |
87 |
88 |
89 | )
90 | }
--------------------------------------------------------------------------------
/ctrl/socks5.go:
--------------------------------------------------------------------------------
1 | package ctrl
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "net"
7 | "net/url"
8 | "time"
9 |
10 | "github.com/go-gost/gosocks5/server"
11 | "github.com/pkg/errors"
12 |
13 | "github.com/go-gost/gosocks5"
14 | log "github.com/kataras/golog"
15 | "github.com/zema1/suo5/netrans"
16 | "github.com/zema1/suo5/suo5"
17 | )
18 |
19 | type Socks5Handler struct {
20 | *suo5.Suo5Client
21 |
22 | ctx context.Context
23 | selector gosocks5.Selector
24 | }
25 |
26 | func NewSocks5Handler(ctx context.Context, client *suo5.Suo5Client) *Socks5Handler {
27 | selector := server.DefaultSelector
28 | if !client.Config.NoAuth() {
29 | selector = server.NewServerSelector([]*url.Userinfo{
30 | url.UserPassword(client.Config.Username, client.Config.Password),
31 | })
32 | }
33 |
34 | return &Socks5Handler{
35 | ctx: ctx,
36 | Suo5Client: client,
37 | selector: selector,
38 | }
39 | }
40 |
41 | func (m *Socks5Handler) Handle(conn net.Conn) error {
42 | defer conn.Close()
43 |
44 | conn = netrans.NewTimeoutConn(conn, 0, time.Second*3)
45 | conn = gosocks5.ServerConn(conn, m.selector)
46 | req, err := gosocks5.ReadRequest(conn)
47 | if err != nil {
48 | return err
49 | }
50 |
51 | if len(m.Config.ExcludeGlobs) != 0 {
52 | for _, g := range m.Config.ExcludeGlobs {
53 | if g.Match(req.Addr.Host) {
54 | log.Debugf("drop connection to %s", req.Addr.Host)
55 | return nil
56 | }
57 | }
58 | }
59 |
60 | log.Infof("start connection to %s", req.Addr.String())
61 | switch req.Cmd {
62 | case gosocks5.CmdConnect:
63 | m.handleConnect(conn, req)
64 | return nil
65 | default:
66 | return fmt.Errorf("%d: unsupported command", gosocks5.CmdUnsupported)
67 | }
68 | }
69 |
70 | func (m *Socks5Handler) handleConnect(conn net.Conn, sockReq *gosocks5.Request) {
71 | streamRW := suo5.NewSuo5Conn(m.ctx, m.Suo5Client)
72 | err := streamRW.ConnectMultiplex(sockReq.Addr.String())
73 | if err != nil {
74 | if sockReq.Addr.String() == "127.0.0.1:0" {
75 | log.Debugf("failed to connect to %s, %v", sockReq.Addr, err)
76 | } else {
77 | log.Warnf("failed to connect to %s, %v", sockReq.Addr, err)
78 | }
79 | ReplyError(conn, err)
80 | return
81 | }
82 | rep := gosocks5.NewReply(gosocks5.Succeeded, nil)
83 | err = rep.Write(conn)
84 | if err != nil {
85 | log.Errorf("write data failed, %s", err)
86 | return
87 | }
88 | log.Infof("successfully connected to %s", sockReq.Addr)
89 |
90 | m.DualPipe(conn, streamRW.ReadWriteCloser, sockReq.Addr.String())
91 |
92 | log.Infof("connection closed, %s", sockReq.Addr)
93 | }
94 |
95 | func ReplyError(conn net.Conn, err error) {
96 | var rep *gosocks5.Reply
97 | if errors.Is(err, suo5.ErrHostUnreachable) {
98 | rep = gosocks5.NewReply(gosocks5.HostUnreachable, nil)
99 | } else if errors.Is(err, suo5.ErrDialFailed) {
100 | rep = gosocks5.NewReply(gosocks5.Failure, nil)
101 | } else if errors.Is(err, suo5.ErrConnRefused) {
102 | rep = gosocks5.NewReply(gosocks5.ConnRefused, nil)
103 | } else {
104 | rep = gosocks5.NewReply(gosocks5.Failure, nil)
105 | }
106 | _ = rep.Write(conn)
107 | }
108 |
--------------------------------------------------------------------------------
/gui/frontend/src/components/ui/calendar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { ChevronLeft, ChevronRight } from "lucide-react"
3 | import { DayPicker } from "react-day-picker"
4 |
5 | import { cn } from "@/lib/utils"
6 | import { buttonVariants } from "@/components/ui/button"
7 |
8 | function Calendar({
9 | className,
10 | classNames,
11 | showOutsideDays = true,
12 | ...props
13 | }: React.ComponentProps) {
14 | return (
15 | .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
39 | : "[&:has([aria-selected])]:rounded-md"
40 | ),
41 | day: cn(
42 | buttonVariants({ variant: "ghost" }),
43 | "size-8 p-0 font-normal aria-selected:opacity-100"
44 | ),
45 | day_range_start:
46 | "day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground",
47 | day_range_end:
48 | "day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground",
49 | day_selected:
50 | "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
51 | day_today: "bg-accent text-accent-foreground",
52 | day_outside:
53 | "day-outside text-muted-foreground aria-selected:text-muted-foreground",
54 | day_disabled: "text-muted-foreground opacity-50",
55 | day_range_middle:
56 | "aria-selected:bg-accent aria-selected:text-accent-foreground",
57 | day_hidden: "invisible",
58 | ...classNames,
59 | }}
60 | components={{
61 | IconLeft: ({ className, ...props }) => (
62 |
63 | ),
64 | IconRight: ({ className, ...props }) => (
65 |
66 | ),
67 | }}
68 | {...props}
69 | />
70 | )
71 | }
72 |
73 | export { Calendar }
74 |
--------------------------------------------------------------------------------
/suo5/classic.go:
--------------------------------------------------------------------------------
1 | package suo5
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "fmt"
7 | "io"
8 | "net"
9 | "net/http"
10 | "strconv"
11 | "strings"
12 | "time"
13 |
14 | log "github.com/kataras/golog"
15 | "github.com/pkg/errors"
16 | )
17 |
18 | type ClassicStreamFactory struct {
19 | *BaseStreamFactory
20 | client *http.Client
21 | }
22 |
23 | func NewClassicStreamFactory(ctx context.Context, config *Suo5Config, client *http.Client) StreamFactory {
24 | s := &ClassicStreamFactory{
25 | BaseStreamFactory: NewBaseStreamFactory(ctx, config),
26 | client: client,
27 | }
28 |
29 | s.OnRemotePlexWrite(func(p []byte) error {
30 | log.Debugf("send remote write request, body len: %d", len(p))
31 | req := s.config.NewRequest(s.ctx, bytes.NewReader(p), int64(len(p)))
32 | resp, err := s.client.Do(req)
33 | if err != nil {
34 | return err
35 | }
36 | defer resp.Body.Close()
37 |
38 | if resp.StatusCode != 200 {
39 | return errors.Wrap(errExpectedRetry, fmt.Sprintf("unexpected status of %d", resp.StatusCode))
40 | }
41 | if resp.ContentLength == 0 {
42 | // log.Debugf("no data from server")
43 | return nil
44 | }
45 |
46 | data, err := io.ReadAll(resp.Body)
47 | if err != nil {
48 | // todo: why listener eof
49 | if !strings.Contains(err.Error(), "unexpected EOF") {
50 | return errors.Wrap(errExpectedRetry, fmt.Sprintf("read body err, %s", err))
51 | }
52 | }
53 | if len(data) == 0 {
54 | log.Debugf("no data from server, empty body")
55 | return nil
56 | }
57 | err = s.DispatchRemoteData(bytes.NewReader(data))
58 | if err != nil {
59 | return errors.Wrap(errExpectedRetry, fmt.Sprintf("dispatch data err, %s", err))
60 | }
61 |
62 | return nil
63 | })
64 |
65 | go func() {
66 | for {
67 | select {
68 | case <-s.ctx.Done():
69 | return
70 | default:
71 | time.Sleep(time.Second * 5)
72 | s.tunnelMu.Lock()
73 | log.Debugf("connection count: %d", len(s.tunnels))
74 | s.tunnelMu.Unlock()
75 | }
76 | }
77 | }()
78 | return s
79 | }
80 |
81 | func (c *ClassicStreamFactory) Spawn(id, address string) (tunnel *TunnelConn, err error) {
82 | tunnel, err = c.Create(id)
83 | if err != nil {
84 | return nil, err
85 | }
86 |
87 | tunnelRef := tunnel
88 | defer func() {
89 | if err != nil && tunnelRef != nil {
90 | tunnelRef.CloseSelf()
91 | }
92 | }()
93 |
94 | host, port, _ := net.SplitHostPort(address)
95 | uport, _ := strconv.Atoi(port)
96 | dialData := BuildBody(NewActionCreate(id, host, uint16(uport)), c.config.RedirectURL, c.config.SessionId, c.config.Mode)
97 |
98 | _, err = tunnel.WriteRaw(dialData, true)
99 | if err != nil {
100 | return nil, errors.Wrap(ErrDialFailed, err.Error())
101 |
102 | }
103 |
104 | // classic 只能通过轮询来获取远端数据
105 | tunnel.SetupActivePoll()
106 |
107 | // recv dial status
108 | serverData, err := tunnel.ReadUnmarshal()
109 | if err != nil {
110 | return nil, errors.Wrap(ErrDialFailed, err.Error())
111 | }
112 |
113 | status := serverData["s"]
114 |
115 | log.Debugf("recv dial response from server: %v", status)
116 | if len(status) != 1 || status[0] != 0x00 {
117 | return nil, errors.Wrap(ErrConnRefused, fmt.Sprintf("status: %v", status))
118 | }
119 |
120 | return tunnel, nil
121 | }
122 |
--------------------------------------------------------------------------------
/gui/frontend/src/components/ui/pagination.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import {
3 | ChevronLeftIcon,
4 | ChevronRightIcon,
5 | MoreHorizontalIcon,
6 | } from "lucide-react"
7 |
8 | import { cn } from "@/lib/utils"
9 | import { Button, buttonVariants } from "@/components/ui/button"
10 |
11 | function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
12 | return (
13 |
20 | )
21 | }
22 |
23 | function PaginationContent({
24 | className,
25 | ...props
26 | }: React.ComponentProps<"ul">) {
27 | return (
28 |
33 | )
34 | }
35 |
36 | function PaginationItem({ ...props }: React.ComponentProps<"li">) {
37 | return
38 | }
39 |
40 | type PaginationLinkProps = {
41 | isActive?: boolean
42 | } & Pick, "size"> &
43 | React.ComponentProps<"a">
44 |
45 | function PaginationLink({
46 | className,
47 | isActive,
48 | size = "icon",
49 | ...props
50 | }: PaginationLinkProps) {
51 | return (
52 |
65 | )
66 | }
67 |
68 | function PaginationPrevious({
69 | className,
70 | ...props
71 | }: React.ComponentProps) {
72 | return (
73 |
79 |
80 | Previous
81 |
82 | )
83 | }
84 |
85 | function PaginationNext({
86 | className,
87 | ...props
88 | }: React.ComponentProps) {
89 | return (
90 |
96 | Next
97 |
98 |
99 | )
100 | }
101 |
102 | function PaginationEllipsis({
103 | className,
104 | ...props
105 | }: React.ComponentProps<"span">) {
106 | return (
107 |
113 |
114 | More pages
115 |
116 | )
117 | }
118 |
119 | export {
120 | Pagination,
121 | PaginationContent,
122 | PaginationLink,
123 | PaginationItem,
124 | PaginationPrevious,
125 | PaginationNext,
126 | PaginationEllipsis,
127 | }
128 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # 更新记录
2 |
3 | ## [1.3.0] 2024.08.28
4 |
5 | ### 新增
6 |
7 | - 增加实验性的 PHP 支持,使用之前请务必认真阅读文档
8 | - 增加配置文件支持,支持导入导出配置文件来做配置复用
9 | - 支持配置域名/IP的过滤规则,支持 Glob 通配符, 如 `*.example.com`
10 | - 默认启用 `cookiejar`,如有问题请手动禁用
11 | - 适当调整 GUI 版的界面布局,更加紧凑和聚焦
12 |
13 | ### 修复
14 |
15 | - 修复 `weblogic` 等中间件内网转发失败问题
16 | - 修复 Windows7 下客户端无法使用的问题
17 | - 修复 `Suo5WebFlux` 拼写错误
18 | - 去除部分无用的默认 Header
19 |
20 | ### 变更
21 |
22 | - 测试失败时,将会直接认为连接不成功,而不是保持监听
23 |
24 | ## [1.2.0] 2024.04.30
25 |
26 | ### 修复
27 |
28 | - 修复部分网站 `TLS` 连接失败的问题 #55
29 | - 更新部分依赖至最新版
30 |
31 | ## [1.1.0] 2024.01.17
32 |
33 | ### 新增
34 |
35 | - 增加 .Net 内存马支持,感谢 [@dust-life](https://github.com/dust-life) 贡献
36 |
37 | ### 修复
38 |
39 | - 修复部分网站 TLS 连接失败的问题
40 | - 修复 TLS 握手失败时连接未关闭的问题
41 |
42 | ### 变更
43 |
44 | - 暂时禁用 HTTP2 的支持
45 |
46 | ## [1.0.0] 2023.12.18
47 |
48 | ### 新增
49 |
50 | - 增加 `aspx` 脚本支持,最低支持 .Net Framework 2.0
51 | - 增加 `WebFlex` 响应式支持,并贴心的提供一键注入 SpringCloudGateway 的代码
52 | - 增加 `cookiejar` 支持,默认不启用
53 | - 增加 `JDK21` 兼容性测试, 完美支持 `JDK21`
54 | - 增加本地写超时的限制,解决某些情况连接数膨胀的问题
55 | - 使用随机 `TLS` 指纹,去除 Go 语言本身的 `TLS` 握手特征
56 | - 使用随机异或密钥,去除连接阶段响应包完全一致的流量特征
57 |
58 | ### 修复
59 |
60 | - 修复界面版日志太多时 OOM 的问题
61 | - 修复上游代理用户名密码大写不生效的问题
62 | - 修复连接测试阶段未使用上游代理的问题
63 |
64 | ## [0.9.0] 2023.06-29
65 |
66 | - 增加脏数据跳过逻辑, 自动计算偏移 #11
67 | - 增加 `jspx` 形式的服务端, 通过全部中间件的测试 #31
68 | - 允许连接测试时的 `EOF` 的情况,解决部分 Listener 内存马连不上的问题
69 |
70 | ### 修复
71 |
72 | - 修复上游代理对连接测试的这个请求不生效的问题
73 |
74 | ## [0.8.0] 2023.05-23
75 |
76 | ### 修复
77 |
78 | - 上一个版本因上游库忘记更新导致的连接超时问题 #28 #29
79 |
80 | ## [0.7.0] 2023-05-17
81 |
82 | ### 新增
83 |
84 | - 增加 WebSphere 全版本支持
85 | - 增加东方通(TongWeb)支持, 部分旧版需要禁用 gzip 才行
86 | - 增加 `-no-gzip` 选项用于禁用响应中的 gzip 压缩
87 | - 上游代理 `-proxy` 增加 http(s) 的支持,不再仅限于
88 | socks5 [#23](https://github.com/zema1/suo5/issues/23) [#25](https://github.com/zema1/suo5/issues/25)
89 | - 去除 Session 相关依赖,优化 stream 读写相关的代码,
90 | 有效解决部分能连上没数据的问题 [#22](https://github.com/zema1/suo5/issues/22)
91 | - 增加代理自测试逻辑,如果没有报错那么代理一定可用
92 | - 重写心跳包逻辑,如果 5s 内有数据读写,就不必发送心跳了
93 | - 基础连接测试的逻辑融合到全双工的判断中,减少一个测试请求
94 |
95 | ### 修复
96 |
97 | - 修复 GUI 版界面版本号错误的问题
98 | - 暂时禁用 darwin 的内存占用显示
99 |
100 | ## [0.6.0] - 2023-04-10
101 |
102 | ### 新增
103 |
104 | - 降低 JDK 依赖到 Java 4, 目前兼容 Java 4 ~ Java 19 全版本
105 | - 新增 Tomcat Weblogic Resin Jetty 等中间件的自动化测试, 下列版本均测试通过:
106 | - Tomcat 4,5,6,7,8,9,10
107 | - Weblogic 10,12,14
108 | - Jboss 4,6
109 | - Jetty 9,10,11
110 | - 更换一个更圆润的图标, 感谢 [@savior-only](https://github.com/savior-only)
111 |
112 | ### 修复
113 |
114 | - 修复 GUI 版本在高版本 Edge 下启动缓慢的问题
115 |
116 | ## [0.5.0] - 2023-03-14
117 |
118 | ### 新增
119 |
120 | - 每 5s 发送一个心跳包避免远端因 `ReadTimeout` 而关闭连接 [#12](https://github.com/zema1/suo5/issues/12)
121 | - 改进地址检查方式,负载均衡的转发判断会更快一点
122 |
123 | ## [0.4.0] - 2023-03-05
124 |
125 | ### 新增
126 |
127 | - 支持在负载均衡场景使用,需要通过 `-r` 指定一个 url,流量将集中到这个 url
128 | - 支持自定义 header,而不仅仅是自定义 User-Agent [#5](https://github.com/zema1/suo5/issues/6)
129 | - 优化连接控制,本地连接关闭后能更快的释放底层连接
130 |
131 | ### 修复
132 |
133 | - 修复命令行版设置认证信息不生效的问题 [#5](https://github.com/zema1/suo5/issues/8)
134 |
135 | ## [0.3.0] - 2023-02-24
136 |
137 | ### 新增
138 |
139 | - 支持自定义连接方法,如 GET, PATCH
140 | - 支持配置上游代理, 仅支持 socks5
141 | - 增加英文文档和变更记录
142 |
143 | ### 修复
144 |
145 | - 修复部分英文语法错误
146 |
147 | ## [0.2.0] - 2023-02-22
148 |
149 | ### 新增
150 |
151 | - 发布第一版本,包含 GUI 和 命令行版
152 | - 使用 Github Action 自动构建所有版本的应用
153 |
--------------------------------------------------------------------------------
/gui/frontend/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/netrans/frame.go:
--------------------------------------------------------------------------------
1 | package netrans
2 |
3 | import (
4 | "crypto/rand"
5 | "encoding/base64"
6 | "encoding/binary"
7 | "errors"
8 | "fmt"
9 | "io"
10 | )
11 |
12 | type DataFrame struct {
13 | Obs []byte
14 | Length uint32
15 | Data []byte
16 | }
17 |
18 | func NewDataFrame(data []byte) *DataFrame {
19 | obs := make([]byte, 2)
20 | _, _ = rand.Read(obs[:])
21 | return &DataFrame{
22 | Obs: obs,
23 | Length: uint32(len(data)),
24 | Data: data,
25 | }
26 | }
27 |
28 | func (d *DataFrame) MarshalBinaryBase64() []byte {
29 | newData := make([]byte, len(d.Data))
30 | copy(newData, d.Data)
31 | for i := 0; i < len(newData); i++ {
32 | newData[i] = newData[i] ^ d.Obs[i%2]
33 | }
34 | newData = []byte(base64.RawURLEncoding.EncodeToString(newData))
35 | newLen := uint32(len(newData))
36 |
37 | result := make([]byte, 4)
38 | binary.BigEndian.PutUint32(result, newLen)
39 | for i := 0; i < len(result); i++ {
40 | result[i] = result[i] ^ d.Obs[i%2]
41 | }
42 |
43 | result = append(d.Obs, result...)
44 | result = []byte(base64.RawURLEncoding.EncodeToString(result))
45 | result = append(result, newData...)
46 | return result
47 | }
48 |
49 | func (d *DataFrame) MarshalBinary() []byte {
50 | result := make([]byte, 4, 4+2+d.Length)
51 | binary.BigEndian.PutUint32(result, d.Length)
52 | result = append(result, d.Obs...)
53 | result = append(result, d.Data...)
54 | for i := 6; i < len(result); i++ {
55 | result[i] = result[i] ^ d.Obs[(i-6)%2]
56 | }
57 | return result
58 | }
59 |
60 | func ReadFrame(r io.Reader) (*DataFrame, error) {
61 | var bs [4]byte
62 | // read xor and magic number
63 | _, err := io.ReadFull(r, bs[:])
64 | if err != nil {
65 | return nil, err
66 | }
67 | fr := &DataFrame{}
68 |
69 | fr.Length = binary.BigEndian.Uint32(bs[:])
70 | if fr.Length > 1024*1024*32 {
71 | return nil, fmt.Errorf("frame is too big, %d", fr.Length)
72 | }
73 | n, err := r.Read(bs[:2])
74 | if n != 2 || err != nil {
75 | return nil, fmt.Errorf("read type error %v", err)
76 | }
77 | fr.Obs = bs[:2]
78 | buf := make([]byte, fr.Length)
79 | _, err = io.ReadFull(r, buf)
80 | if err != nil {
81 | return nil, fmt.Errorf("read data error: %v", err)
82 | }
83 | for i := 0; i < len(buf); i++ {
84 | buf[i] = buf[i] ^ fr.Obs[i%2]
85 | }
86 | fr.Data = buf
87 | return fr, nil
88 | }
89 |
90 | // ReadFrameBase64 reads a base64 encoded DataFrame from an io.Reader.
91 | func ReadFrameBase64(r io.Reader) (*DataFrame, error) {
92 | // Read the first part (length and obs)
93 | // The first part is base64 encoded from 6 bytes (4 bytes length + 2 bytes obs).
94 | // Base64 encoding 6 bytes results in 8 bytes.
95 | headerBase64 := make([]byte, 8)
96 | n, err := io.ReadFull(r, headerBase64)
97 | if err != nil {
98 | return nil, errors.New("failed to read header base64: " + err.Error())
99 | }
100 | if n != 8 {
101 | return nil, errors.New("incomplete header base64 read")
102 | }
103 | headerBytes, err := base64.RawURLEncoding.DecodeString(string(headerBase64))
104 | if err != nil {
105 | return nil, errors.New("failed to decode header base64: " + err.Error())
106 | }
107 | if len(headerBytes) != 6 {
108 | return nil, errors.New("invalid header length, expected 6 bytes after decoding")
109 | }
110 | // Extract obs and length from the decoded header
111 | obs := make([]byte, 2)
112 | copy(obs, headerBytes[:2])
113 | for i := 2; i < 6; i++ {
114 | headerBytes[i] = headerBytes[i] ^ obs[(i-2)%2]
115 | }
116 | dataLength := binary.BigEndian.Uint32(headerBytes[2:])
117 |
118 | buf := make([]byte, dataLength)
119 | _, err = io.ReadFull(r, buf)
120 | if err != nil {
121 | return nil, errors.New("failed to read data base64: " + err.Error())
122 | }
123 | dataBytes, err := base64.RawURLEncoding.DecodeString(string(buf))
124 | if err != nil {
125 | return nil, errors.New("failed to decode data base64: " + err.Error())
126 | }
127 | // Decode the data using obs
128 | for i := 0; i < len(dataBytes); i++ {
129 | dataBytes[i] = dataBytes[i] ^ obs[i%2]
130 | }
131 | return &DataFrame{
132 | Length: uint32(len(dataBytes)),
133 | Obs: obs,
134 | Data: dataBytes,
135 | }, nil
136 | }
137 |
--------------------------------------------------------------------------------
/suo5/protocol.go:
--------------------------------------------------------------------------------
1 | package suo5
2 |
3 | import (
4 | "bytes"
5 | "encoding/binary"
6 | "fmt"
7 | "io"
8 | "math/rand"
9 | "strconv"
10 |
11 | "github.com/zema1/suo5/netrans"
12 | )
13 |
14 | type ConnectionType string
15 |
16 | const (
17 | Checking ConnectionType = "checking"
18 | AutoDuplex ConnectionType = "auto"
19 | FullDuplex ConnectionType = "full"
20 | HalfDuplex ConnectionType = "half"
21 | Classic ConnectionType = "classic"
22 | )
23 |
24 | func AllConnectionTypes() []ConnectionType {
25 | return []ConnectionType{
26 | Checking,
27 | AutoDuplex,
28 | FullDuplex,
29 | HalfDuplex,
30 | Classic,
31 | }
32 | }
33 |
34 | func (c ConnectionType) Bin() byte {
35 | switch c {
36 | case Checking:
37 | return 0x00
38 | case FullDuplex:
39 | return 0x01
40 | case HalfDuplex:
41 | return 0x02
42 | case Classic:
43 | return 0x03
44 | default:
45 | return 0xff
46 | }
47 | }
48 |
49 | const (
50 | ActionCreate byte = 0x00
51 | ActionData byte = 0x01
52 | ActionDelete byte = 0x02
53 | ActionStatus byte = 0x03
54 | ActionHeartbeat byte = 0x10
55 | ActionDirty byte = 0x11
56 | )
57 |
58 | func BuildBody(m map[string][]byte, redirect, sid string, ct ConnectionType) []byte {
59 | if len(redirect) != 0 {
60 | m["r"] = []byte(redirect)
61 | }
62 | if len(sid) != 0 {
63 | m["sid"] = []byte(sid)
64 | }
65 | m["m"] = []byte{ct.Bin()}
66 | // some junk data
67 | m["_"] = RandBytes(64)
68 | return netrans.NewDataFrame(Marshal(m)).MarshalBinaryBase64()
69 | }
70 |
71 | func NewActionCreate(id, addr string, port uint16) map[string][]byte {
72 | m := make(map[string][]byte)
73 | m["ac"] = []byte{ActionCreate}
74 | m["id"] = []byte(id)
75 | m["h"] = []byte(addr)
76 | m["p"] = []byte(strconv.Itoa(int(port)))
77 |
78 | return m
79 | }
80 |
81 | func NewActionData(id string, data []byte) map[string][]byte {
82 | m := make(map[string][]byte)
83 | m["ac"] = []byte{ActionData}
84 | m["id"] = []byte(id)
85 | m["dt"] = []byte(data)
86 | return m
87 | }
88 |
89 | func NewActionDelete(id string) map[string][]byte {
90 | m := make(map[string][]byte)
91 | m["ac"] = []byte{ActionDelete}
92 | m["id"] = []byte(id)
93 | return m
94 | }
95 |
96 | func NewActionHeartbeat(id string) map[string][]byte {
97 | m := make(map[string][]byte)
98 | m["ac"] = []byte{ActionHeartbeat}
99 | m["id"] = []byte(id)
100 | return m
101 | }
102 |
103 | // 定义一个最简的序列化协议,k,v 交替,每一项是len+data
104 | // 其中 k 最长 255,v 最长 MaxUInt32
105 | func Marshal(m map[string][]byte) []byte {
106 | var buf bytes.Buffer
107 | u32Buf := make([]byte, 4)
108 | for k, v := range m {
109 | buf.WriteByte(byte(len(k)))
110 | buf.WriteString(k)
111 | binary.BigEndian.PutUint32(u32Buf, uint32(len(v)))
112 | buf.Write(u32Buf)
113 | buf.Write(v)
114 | }
115 | return buf.Bytes()
116 | }
117 |
118 | func Unmarshal(bs []byte) (map[string][]byte, error) {
119 | m := make(map[string][]byte)
120 | total := len(bs)
121 | for i := 0; i < total-1; {
122 | kLen := int(bs[i])
123 | i += 1
124 |
125 | if i+kLen >= total {
126 | return nil, fmt.Errorf("unexpected eof when read key")
127 | }
128 | key := string(bs[i : i+kLen])
129 | i += kLen
130 |
131 | if i+4 > total {
132 | return nil, fmt.Errorf("unexpected eof when read value size")
133 | }
134 | vLen := int(binary.BigEndian.Uint32(bs[i : i+4]))
135 | i += 4
136 |
137 | if i+vLen > total {
138 | return nil, fmt.Errorf("unexpected eof when read value")
139 | }
140 | value := bs[i : i+vLen]
141 | m[key] = value
142 | i += vLen
143 | }
144 | return m, nil
145 | }
146 |
147 | func UnmarshalFrameWithBuffer(r io.Reader) (map[string][]byte, []byte, error) {
148 | var buf bytes.Buffer
149 | teeReader := io.TeeReader(r, &buf)
150 | fr, err := netrans.ReadFrameBase64(teeReader)
151 | if err != nil {
152 | return nil, buf.Bytes(), err
153 | }
154 |
155 | serverData, err := Unmarshal(fr.Data)
156 | if err != nil {
157 | return nil, nil, err
158 | }
159 | return serverData, buf.Bytes(), nil
160 | }
161 |
162 | func RandBytes(max int) []byte {
163 | length := rand.Intn(max)
164 | b := make([]byte, length)
165 | rand.Read(b) //nolint:staticcheck
166 | return b
167 | }
168 |
--------------------------------------------------------------------------------
/gui/frontend/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as DialogPrimitive from "@radix-ui/react-dialog"
3 | import { XIcon } from "lucide-react"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | function Dialog({
8 | ...props
9 | }: React.ComponentProps) {
10 | return
11 | }
12 |
13 | function DialogTrigger({
14 | ...props
15 | }: React.ComponentProps) {
16 | return
17 | }
18 |
19 | function DialogPortal({
20 | ...props
21 | }: React.ComponentProps) {
22 | return
23 | }
24 |
25 | function DialogClose({
26 | ...props
27 | }: React.ComponentProps) {
28 | return
29 | }
30 |
31 | function DialogOverlay({
32 | className,
33 | ...props
34 | }: React.ComponentProps) {
35 | return (
36 |
44 | )
45 | }
46 |
47 | function DialogContent({
48 | className,
49 | children,
50 | ...props
51 | }: React.ComponentProps) {
52 | return (
53 |
54 |
55 |
63 | {children}
64 |
65 |
66 | Close
67 |
68 |
69 |
70 | )
71 | }
72 |
73 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
74 | return (
75 |
80 | )
81 | }
82 |
83 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
84 | return (
85 |
93 | )
94 | }
95 |
96 | function DialogTitle({
97 | className,
98 | ...props
99 | }: React.ComponentProps) {
100 | return (
101 |
106 | )
107 | }
108 |
109 | function DialogDescription({
110 | className,
111 | ...props
112 | }: React.ComponentProps) {
113 | return (
114 |
119 | )
120 | }
121 |
122 | export {
123 | Dialog,
124 | DialogClose,
125 | DialogContent,
126 | DialogDescription,
127 | DialogFooter,
128 | DialogHeader,
129 | DialogOverlay,
130 | DialogPortal,
131 | DialogTitle,
132 | DialogTrigger,
133 | }
134 |
--------------------------------------------------------------------------------
/gui/frontend/src/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { Slot } from "@radix-ui/react-slot"
4 | import {
5 | Controller,
6 | FormProvider,
7 | useFormContext,
8 | useFormState,
9 | type ControllerProps,
10 | type FieldPath,
11 | type FieldValues,
12 | } from "react-hook-form"
13 |
14 | import { cn } from "@/lib/utils"
15 | import { Label } from "@/components/ui/label"
16 |
17 | const Form = FormProvider
18 |
19 | type FormFieldContextValue<
20 | TFieldValues extends FieldValues = FieldValues,
21 | TName extends FieldPath = FieldPath,
22 | > = {
23 | name: TName
24 | }
25 |
26 | const FormFieldContext = React.createContext(
27 | {} as FormFieldContextValue
28 | )
29 |
30 | const FormField = <
31 | TFieldValues extends FieldValues = FieldValues,
32 | TName extends FieldPath = FieldPath,
33 | >({
34 | ...props
35 | }: ControllerProps) => {
36 | return (
37 |
38 |
39 |
40 | )
41 | }
42 |
43 | const useFormField = () => {
44 | const fieldContext = React.useContext(FormFieldContext)
45 | const itemContext = React.useContext(FormItemContext)
46 | const { getFieldState } = useFormContext()
47 | const formState = useFormState({ name: fieldContext.name })
48 | const fieldState = getFieldState(fieldContext.name, formState)
49 |
50 | if (!fieldContext) {
51 | throw new Error("useFormField should be used within ")
52 | }
53 |
54 | const { id } = itemContext
55 |
56 | return {
57 | id,
58 | name: fieldContext.name,
59 | formItemId: `${id}-form-item`,
60 | formDescriptionId: `${id}-form-item-description`,
61 | formMessageId: `${id}-form-item-message`,
62 | ...fieldState,
63 | }
64 | }
65 |
66 | type FormItemContextValue = {
67 | id: string
68 | }
69 |
70 | const FormItemContext = React.createContext(
71 | {} as FormItemContextValue
72 | )
73 |
74 | function FormItem({ className, ...props }: React.ComponentProps<"div">) {
75 | const id = React.useId()
76 |
77 | return (
78 |
79 |
84 |
85 | )
86 | }
87 |
88 | function FormLabel({
89 | className,
90 | ...props
91 | }: React.ComponentProps) {
92 | const { error, formItemId } = useFormField()
93 |
94 | return (
95 |
102 | )
103 | }
104 |
105 | function FormControl({ ...props }: React.ComponentProps) {
106 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
107 |
108 | return (
109 |
120 | )
121 | }
122 |
123 | function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
124 | const { formDescriptionId } = useFormField()
125 |
126 | return (
127 |
133 | )
134 | }
135 |
136 | function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
137 | const { error, formMessageId } = useFormField()
138 | const body = error ? String(error?.message ?? "") : props.children
139 |
140 | if (!body) {
141 | return null
142 | }
143 |
144 | return (
145 |
151 | {body}
152 |
153 | )
154 | }
155 |
156 | export {
157 | useFormField,
158 | Form,
159 | FormItem,
160 | FormLabel,
161 | FormControl,
162 | FormDescription,
163 | FormMessage,
164 | FormField,
165 | }
166 |
--------------------------------------------------------------------------------
/gui/frontend/src/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
5 |
6 | import { cn } from "@/lib/utils"
7 | import { buttonVariants } from "@/components/ui/button"
8 |
9 | function AlertDialog({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function AlertDialogTrigger({
16 | ...props
17 | }: React.ComponentProps) {
18 | return (
19 |
20 | )
21 | }
22 |
23 | function AlertDialogPortal({
24 | ...props
25 | }: React.ComponentProps) {
26 | return (
27 |
28 | )
29 | }
30 |
31 | function AlertDialogOverlay({
32 | className,
33 | ...props
34 | }: React.ComponentProps) {
35 | return (
36 |
44 | )
45 | }
46 |
47 | function AlertDialogContent({
48 | className,
49 | ...props
50 | }: React.ComponentProps) {
51 | return (
52 |
53 |
54 |
62 |
63 | )
64 | }
65 |
66 | function AlertDialogHeader({
67 | className,
68 | ...props
69 | }: React.ComponentProps<"div">) {
70 | return (
71 |
76 | )
77 | }
78 |
79 | function AlertDialogFooter({
80 | className,
81 | ...props
82 | }: React.ComponentProps<"div">) {
83 | return (
84 |
92 | )
93 | }
94 |
95 | function AlertDialogTitle({
96 | className,
97 | ...props
98 | }: React.ComponentProps) {
99 | return (
100 |
105 | )
106 | }
107 |
108 | function AlertDialogDescription({
109 | className,
110 | ...props
111 | }: React.ComponentProps) {
112 | return (
113 |
118 | )
119 | }
120 |
121 | function AlertDialogAction({
122 | className,
123 | ...props
124 | }: React.ComponentProps) {
125 | return (
126 |
130 | )
131 | }
132 |
133 | function AlertDialogCancel({
134 | className,
135 | ...props
136 | }: React.ComponentProps) {
137 | return (
138 |
142 | )
143 | }
144 |
145 | export {
146 | AlertDialog,
147 | AlertDialogPortal,
148 | AlertDialogOverlay,
149 | AlertDialogTrigger,
150 | AlertDialogContent,
151 | AlertDialogHeader,
152 | AlertDialogFooter,
153 | AlertDialogTitle,
154 | AlertDialogDescription,
155 | AlertDialogAction,
156 | AlertDialogCancel,
157 | }
158 |
--------------------------------------------------------------------------------
/gui/frontend/src/components/ui/drawer.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Drawer as DrawerPrimitive } from "vaul"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | function Drawer({
7 | ...props
8 | }: React.ComponentProps) {
9 | return
10 | }
11 |
12 | function DrawerTrigger({
13 | ...props
14 | }: React.ComponentProps) {
15 | return
16 | }
17 |
18 | function DrawerPortal({
19 | ...props
20 | }: React.ComponentProps) {
21 | return
22 | }
23 |
24 | function DrawerClose({
25 | ...props
26 | }: React.ComponentProps) {
27 | return
28 | }
29 |
30 | function DrawerOverlay({
31 | className,
32 | ...props
33 | }: React.ComponentProps) {
34 | return (
35 |
43 | )
44 | }
45 |
46 | function DrawerContent({
47 | className,
48 | children,
49 | ...props
50 | }: React.ComponentProps) {
51 | return (
52 |
53 |
54 |
66 |
67 | {children}
68 |
69 |
70 | )
71 | }
72 |
73 | function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
74 | return (
75 |
80 | )
81 | }
82 |
83 | function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
84 | return (
85 |
90 | )
91 | }
92 |
93 | function DrawerTitle({
94 | className,
95 | ...props
96 | }: React.ComponentProps) {
97 | return (
98 |
103 | )
104 | }
105 |
106 | function DrawerDescription({
107 | className,
108 | ...props
109 | }: React.ComponentProps) {
110 | return (
111 |
116 | )
117 | }
118 |
119 | export {
120 | Drawer,
121 | DrawerPortal,
122 | DrawerOverlay,
123 | DrawerTrigger,
124 | DrawerClose,
125 | DrawerContent,
126 | DrawerHeader,
127 | DrawerFooter,
128 | DrawerTitle,
129 | DrawerDescription,
130 | }
131 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release suo5
2 |
3 | on:
4 | push:
5 | branches:
6 | - 'v2'
7 | - 'main'
8 | release:
9 | types: [ published ]
10 |
11 | permissions:
12 | contents: read
13 |
14 | jobs:
15 | build-binary:
16 | name: Build cli
17 | strategy:
18 | fail-fast: true
19 | matrix:
20 | include:
21 | - os: windows
22 | arch: amd64
23 | output: suo5-windows-amd64.exe
24 | - os: darwin
25 | arch: amd64
26 | output: suo5-darwin-amd64
27 | - os: darwin
28 | arch: arm64
29 | output: suo5-darwin-arm64
30 | - os: linux
31 | arch: amd64
32 | output: suo5-linux-amd64
33 | - os: linux
34 | arch: arm64
35 | output: suo5-linux-arm64
36 | runs-on: ubuntu-latest
37 | env:
38 | CGO_ENABLED: 0
39 | GOOS: ${{ matrix.os }}
40 | GOARCH: ${{ matrix.arch }}
41 | steps:
42 | - uses: actions/checkout@v4
43 | with:
44 | submodules: recursive
45 | - uses: actions/setup-go@v5
46 | with:
47 | go-version: '1.20'
48 | - run: go build -trimpath --tags "neoreg suo5" -ldflags "-w -s -extldflags '-static' -X main.Version=${{ github.ref_name }}" -o target/${{ matrix.output }}
49 | - uses: actions/upload-artifact@v4
50 | with:
51 | name: target-binary-${{ matrix.os }}-${{ matrix.arch }}
52 | path: target/*
53 |
54 | build-gui:
55 | name: Build gui
56 | strategy:
57 | fail-fast: false
58 | matrix:
59 | include:
60 | - os: windows-latest
61 | platform: windows/amd64
62 | output: suo5-gui-windows.exe
63 | - os: macos-latest
64 | platform: darwin/universal
65 | # wails bug, mac 的 output file 不生效, 先用这个保证能用
66 | output: suo5
67 | - os: ubuntu-latest
68 | platform: linux/amd64
69 | output: suo5-gui-linux
70 | runs-on: ${{ matrix.os }}
71 | steps:
72 | - uses: actions/checkout@v4
73 | with:
74 | submodules: recursive
75 | - name: Setup NodeJS
76 | uses: actions/setup-node@v4
77 | with:
78 | node-version: 18
79 | - name: Bump manifest version
80 | if: startsWith(github.ref, 'refs/tags/')
81 | working-directory: gui
82 | run: node version.js ${{ github.ref_name }}
83 | - run: npm install && npm run build
84 | working-directory: gui/frontend
85 | - uses: dAppServer/wails-build-action@main
86 | continue-on-error: true
87 | with:
88 | build-name: ${{ matrix.output }}
89 | build-platform: ${{ matrix.platform }}
90 | app-working-directory: gui
91 | nsis: false
92 | go-version: '1.20'
93 | wails-version: 'v2.9.1'
94 | package: false
95 | - if: runner.os == 'macOS'
96 | shell: bash
97 | working-directory: gui
98 | run: |
99 | rm -rf ./build/bin/${{ matrix.output }}.app.zip
100 | ditto -c -k --keepParent ./build/bin/${{matrix.output}}.app ./build/bin/${{matrix.output}}.app.zip
101 | rm -rf ./build/bin/${{ matrix.output }}.app
102 |
103 | - uses: actions/upload-artifact@v4
104 | with:
105 | name: target-gui-${{ matrix.os }}
106 | path: gui/build/bin/*
107 |
108 | collect-release:
109 | name: Collect and release
110 | needs: [ build-binary, build-gui ]
111 | runs-on: ubuntu-latest
112 | permissions:
113 | contents: write
114 | steps:
115 | - uses: actions/checkout@v4
116 | - uses: actions/download-artifact@v4
117 | with:
118 | pattern: target-binary-*
119 | merge-multiple: true
120 | path: target
121 | - uses: actions/download-artifact@v4
122 | with:
123 | pattern: target-gui-*
124 | merge-multiple: true
125 | path: target
126 | - run: ls -al target && ls -R target/
127 | - working-directory: target
128 | run: |
129 | rm -rf suo5-amd64-installer.exe
130 | rm -rf suo5.pkg
131 | mv suo5.app.zip suo5-gui-darwin.app.zip
132 | - run: ls -al target && ls -R target/ && file target/
133 | - uses: actions/upload-artifact@v4
134 | with:
135 | name: final-release
136 | path: target/*
137 |
138 | # release assets
139 | - uses: softprops/action-gh-release@v2
140 | if: startsWith(github.ref, 'refs/tags/')
141 | with:
142 | files: target/*
143 |
--------------------------------------------------------------------------------
/gui/frontend/src/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as SheetPrimitive from "@radix-ui/react-dialog"
3 | import { XIcon } from "lucide-react"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | function Sheet({ ...props }: React.ComponentProps) {
8 | return
9 | }
10 |
11 | function SheetTrigger({
12 | ...props
13 | }: React.ComponentProps) {
14 | return
15 | }
16 |
17 | function SheetClose({
18 | ...props
19 | }: React.ComponentProps) {
20 | return
21 | }
22 |
23 | function SheetPortal({
24 | ...props
25 | }: React.ComponentProps) {
26 | return
27 | }
28 |
29 | function SheetOverlay({
30 | className,
31 | ...props
32 | }: React.ComponentProps) {
33 | return (
34 |
42 | )
43 | }
44 |
45 | function SheetContent({
46 | className,
47 | children,
48 | side = "right",
49 | ...props
50 | }: React.ComponentProps & {
51 | side?: "top" | "right" | "bottom" | "left"
52 | }) {
53 | return (
54 |
55 |
56 |
72 | {children}
73 |
74 |
75 | Close
76 |
77 |
78 |
79 | )
80 | }
81 |
82 | function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
83 | return (
84 |
89 | )
90 | }
91 |
92 | function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
93 | return (
94 |
99 | )
100 | }
101 |
102 | function SheetTitle({
103 | className,
104 | ...props
105 | }: React.ComponentProps) {
106 | return (
107 |
112 | )
113 | }
114 |
115 | function SheetDescription({
116 | className,
117 | ...props
118 | }: React.ComponentProps) {
119 | return (
120 |
125 | )
126 | }
127 |
128 | export {
129 | Sheet,
130 | SheetTrigger,
131 | SheetClose,
132 | SheetContent,
133 | SheetHeader,
134 | SheetFooter,
135 | SheetTitle,
136 | SheetDescription,
137 | }
138 |
--------------------------------------------------------------------------------
/gui/build/windows/installer/project.nsi:
--------------------------------------------------------------------------------
1 | Unicode true
2 |
3 | ####
4 | ## Please note: Template replacements don't work in this file. They are provided with default defines like
5 | ## mentioned underneath.
6 | ## If the keyword is not defined, "wails_tools.nsh" will populate them with the values from ProjectInfo.
7 | ## If they are defined here, "wails_tools.nsh" will not touch them. This allows to use this project.nsi manually
8 | ## from outside of Wails for debugging and development of the installer.
9 | ##
10 | ## For development first make a wails nsis build to populate the "wails_tools.nsh":
11 | ## > wails build --target windows/amd64 --nsis
12 | ## Then you can call makensis on this file with specifying the path to your binary:
13 | ## For a AMD64 only installer:
14 | ## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
15 | ## For a ARM64 only installer:
16 | ## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
17 | ## For a installer with both architectures:
18 | ## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
19 | ####
20 | ## The following information is taken from the ProjectInfo file, but they can be overwritten here.
21 | ####
22 | ## !define INFO_PROJECTNAME "MyProject" # Default "{{.Name}}"
23 | ## !define INFO_COMPANYNAME "MyCompany" # Default "{{.Info.CompanyName}}"
24 | ## !define INFO_PRODUCTNAME "MyProduct" # Default "{{.Info.ProductName}}"
25 | ## !define INFO_PRODUCTVERSION "1.0.0" # Default "{{.Info.ProductVersion}}"
26 | ## !define INFO_COPYRIGHT "Copyright" # Default "{{.Info.Copyright}}"
27 | ###
28 | ## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
29 | ## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
30 | ####
31 | ## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
32 | ####
33 | ## Include the wails tools
34 | ####
35 | !include "wails_tools.nsh"
36 |
37 | # The version information for this two must consist of 4 parts
38 | VIProductVersion "${INFO_PRODUCTVERSION}.0"
39 | VIFileVersion "${INFO_PRODUCTVERSION}.0"
40 |
41 | VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
42 | VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
43 | VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
44 | VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
45 | VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
46 | VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
47 |
48 | !include "MUI.nsh"
49 |
50 | !define MUI_ICON "..\icon.ico"
51 | !define MUI_UNICON "..\icon.ico"
52 | # !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
53 | !define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
54 | !define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
55 |
56 | !insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
57 | # !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
58 | !insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
59 | !insertmacro MUI_PAGE_INSTFILES # Installing page.
60 | !insertmacro MUI_PAGE_FINISH # Finished installation page.
61 |
62 | !insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page
63 |
64 | !insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
65 |
66 | ## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
67 | #!uninstfinalize 'signtool --file "%1"'
68 | #!finalize 'signtool --file "%1"'
69 |
70 | Name "${INFO_PRODUCTNAME}"
71 | OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
72 | InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
73 | ShowInstDetails show # This will always show the installation details.
74 |
75 | Function .onInit
76 | !insertmacro wails.checkArchitecture
77 | FunctionEnd
78 |
79 | Section
80 | !insertmacro wails.webview2runtime
81 |
82 | SetOutPath $INSTDIR
83 |
84 | !insertmacro wails.files
85 |
86 | CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
87 | CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
88 |
89 | !insertmacro wails.writeUninstaller
90 | SectionEnd
91 |
92 | Section "uninstall"
93 | RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
94 |
95 | RMDir /r $INSTDIR
96 |
97 | Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
98 | Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
99 |
100 | !insertmacro wails.deleteUninstaller
101 | SectionEnd
102 |
--------------------------------------------------------------------------------
/.github/actions/run-tests/action.yml:
--------------------------------------------------------------------------------
1 | name: Run Suo5 Tests
2 | description: Run standard suo5 tests with optional no-gzip flag
3 | inputs:
4 | no_gzip:
5 | description: "Add -no-gzip flag to tests"
6 | default: 'false'
7 | required: false
8 | test_auto:
9 | description: "Run auto mode tests"
10 | default: 'true'
11 | required: false
12 | test_full:
13 | description: "Run full mode tests"
14 | default: 'true'
15 | required: false
16 | test_half:
17 | description: "Run half mode tests"
18 | default: 'true'
19 | required: false
20 | test_classic:
21 | description: "Run classic mode tests"
22 | default: 'true'
23 | required: false
24 | test_inner:
25 | description: "Run nginx inner URL tests"
26 | default: 'true'
27 | required: false
28 | test_top:
29 | description: "Run nginx top URL tests"
30 | default: 'true'
31 | required: false
32 | test_redirect:
33 | description: "Run redirect URL tests"
34 | default: 'true'
35 | required: false
36 | redirect_path:
37 | description: "Redirect path for building redirect URL"
38 | default: '/assets/suo5.jsp'
39 | required: false
40 | nginx_url:
41 | description: "Nginx URL for tests"
42 | default: 'http://127.0.0.1:80/assets/suo5.jsp'
43 | required: false
44 | nginx_inner_url:
45 | description: "Nginx inner URL for tests"
46 | default: 'http://127.0.0.1:81/assets/suo5.jsp'
47 | required: false
48 | direct_url:
49 | description: "Direct URL for tests"
50 | default: 'http://127.0.0.1:82/assets/suo5.jsp'
51 | required: false
52 | service_port:
53 | description: "Service port for the application"
54 | required: true
55 | runs:
56 | using: composite
57 | steps:
58 | - shell: bash
59 | run: |
60 | set -ex
61 | ./helper ready -u ${{ inputs.direct_url }} -t 60
62 | chmod +x ./suo5_test
63 |
64 | # Set gzip flag based on input
65 | GZIP_FLAG=""
66 | if [ "${{ inputs.no_gzip }}" = "true" ]; then
67 | GZIP_FLAG="-no-gzip"
68 | fi
69 |
70 | # Redirect URL
71 | REDIRECT_URL=""
72 | if [ "${{ inputs.test_redirect }}" = "true" ]; then
73 | SRV3_IP=$(docker inspect $(docker ps --filter "label=com.docker.compose.service=srv3" --format "{{.ID}}") --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
74 | REDIRECT_URL="http://$SRV3_IP:${{ inputs.service_port }}${{ inputs.redirect_path }}"
75 | echo "Redirect URL: $REDIRECT_URL"
76 | fi
77 |
78 | # Helper function to run test if enabled
79 | run_test() {
80 | local enabled=$1
81 | local url=$2
82 | local mode=$3
83 | local retry=$4
84 | local redirect=$5
85 |
86 | if [ "$enabled" = "true" ]; then
87 | ./suo5_test -debug -t "$url" -T https://example.com/ -mode "$mode" $GZIP_FLAG --retry $retry -r "$redirect"
88 | fi
89 | }
90 |
91 | echo "Run direct URL tests"
92 | run_test "${{ inputs.test_auto }}" "${{ inputs.direct_url }}" "auto" 0 ""
93 | run_test "${{ inputs.test_full }}" "${{ inputs.direct_url }}" "full" 0 ""
94 | run_test "${{ inputs.test_half }}" "${{ inputs.direct_url }}" "half" 0 ""
95 | run_test "${{ inputs.test_classic }}" "${{ inputs.direct_url }}" "classic" 0 ""
96 |
97 | if [ "${{ inputs.test_inner }}" = "true" ]; then
98 | echo "Run nginx inner URL tests with retry"
99 | run_test "${{ inputs.test_auto }}" "${{ inputs.nginx_inner_url }}" "auto" 5 ""
100 | run_test "${{ inputs.test_half }}" "${{ inputs.nginx_inner_url }}" "half" 5 ""
101 | run_test "${{ inputs.test_classic }}" "${{ inputs.nginx_inner_url }}" "classic" 5 ""
102 |
103 | if [ "${{ inputs.test_redirect }}" = "true" ]; then
104 | echo "Run nginx inner URL tests with redirect"
105 | run_test "${{ inputs.test_auto }}" "${{ inputs.nginx_inner_url }}" "auto" 5 "$REDIRECT_URL"
106 | run_test "${{ inputs.test_half }}" "${{ inputs.nginx_inner_url }}" "half" 5 "$REDIRECT_URL"
107 | run_test "${{ inputs.test_classic }}" "${{ inputs.nginx_inner_url }}" "classic" 5 "$REDIRECT_URL"
108 | fi
109 | fi
110 |
111 | if [ "${{ inputs.test_top }}" = "true" ]; then
112 | echo "Run nginx TOP URL tests with retry"
113 | run_test "${{ inputs.test_auto }}" "${{ inputs.nginx_url }}" "auto" 5 ""
114 | run_test "${{ inputs.test_classic }}" "${{ inputs.nginx_url }}" "classic" 5 ""
115 |
116 | if [ "${{ inputs.test_redirect }}" = "true" ]; then
117 | echo "Run nginx TOP URL tests with redirect"
118 | run_test "${{ inputs.test_auto }}" "${{ inputs.nginx_url }}" "auto" 5 "$REDIRECT_URL"
119 | run_test "${{ inputs.test_classic }}" "${{ inputs.nginx_url }}" "classic" 5 "$REDIRECT_URL"
120 | fi
121 | fi
122 |
--------------------------------------------------------------------------------
/gui/frontend/src/index.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 | @import "tw-animate-css";
3 |
4 | @custom-variant dark (&:is(.dark *));
5 |
6 | @theme inline {
7 | --radius-sm: calc(var(--radius) - 4px);
8 | --radius-md: calc(var(--radius) - 2px);
9 | --radius-lg: var(--radius);
10 | --radius-xl: calc(var(--radius) + 4px);
11 | --color-background: var(--background);
12 | --color-foreground: var(--foreground);
13 | --color-card: var(--card);
14 | --color-card-foreground: var(--card-foreground);
15 | --color-popover: var(--popover);
16 | --color-popover-foreground: var(--popover-foreground);
17 | --color-primary: var(--primary);
18 | --color-primary-foreground: var(--primary-foreground);
19 | --color-secondary: var(--secondary);
20 | --color-secondary-foreground: var(--secondary-foreground);
21 | --color-muted: var(--muted);
22 | --color-muted-foreground: var(--muted-foreground);
23 | --color-accent: var(--accent);
24 | --color-accent-foreground: var(--accent-foreground);
25 | --color-destructive: var(--destructive);
26 | --color-border: var(--border);
27 | --color-input: var(--input);
28 | --color-ring: var(--ring);
29 | --color-chart-1: var(--chart-1);
30 | --color-chart-2: var(--chart-2);
31 | --color-chart-3: var(--chart-3);
32 | --color-chart-4: var(--chart-4);
33 | --color-chart-5: var(--chart-5);
34 | --color-sidebar: var(--sidebar);
35 | --color-sidebar-foreground: var(--sidebar-foreground);
36 | --color-sidebar-primary: var(--sidebar-primary);
37 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
38 | --color-sidebar-accent: var(--sidebar-accent);
39 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
40 | --color-sidebar-border: var(--sidebar-border);
41 | --color-sidebar-ring: var(--sidebar-ring);
42 | }
43 |
44 | @layer base {
45 | * {
46 | @apply border-border outline-ring/50;
47 | }
48 |
49 | body {
50 | @apply bg-background text-foreground;
51 | }
52 | }
53 |
54 | :root {
55 | --radius: 0.3rem;
56 | --background: oklch(1 0 0);
57 | --foreground: oklch(0.141 0.005 285.823);
58 | --card: oklch(1 0 0);
59 | --card-foreground: oklch(0.141 0.005 285.823);
60 | --popover: oklch(1 0 0);
61 | --popover-foreground: oklch(0.141 0.005 285.823);
62 | --primary: oklch(0.21 0.006 285.885);
63 | --primary-foreground: oklch(0.985 0 0);
64 | --secondary: oklch(0.967 0.001 286.375);
65 | --secondary-foreground: oklch(0.21 0.006 285.885);
66 | --muted: oklch(0.967 0.001 286.375);
67 | --muted-foreground: oklch(0.552 0.016 285.938);
68 | --accent: oklch(0.967 0.001 286.375);
69 | --accent-foreground: oklch(0.21 0.006 285.885);
70 | --destructive: oklch(0.577 0.245 27.325);
71 | --border: oklch(0.92 0.004 286.32);
72 | --input: oklch(0.92 0.004 286.32);
73 | --ring: oklch(0.705 0.015 286.067);
74 | --chart-1: oklch(0.646 0.222 41.116);
75 | --chart-2: oklch(0.6 0.118 184.704);
76 | --chart-3: oklch(0.398 0.07 227.392);
77 | --chart-4: oklch(0.828 0.189 84.429);
78 | --chart-5: oklch(0.769 0.188 70.08);
79 | --sidebar: oklch(0.985 0 0);
80 | --sidebar-foreground: oklch(0.141 0.005 285.823);
81 | --sidebar-primary: oklch(0.21 0.006 285.885);
82 | --sidebar-primary-foreground: oklch(0.985 0 0);
83 | --sidebar-accent: oklch(0.967 0.001 286.375);
84 | --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
85 | --sidebar-border: oklch(0.92 0.004 286.32);
86 | --sidebar-ring: oklch(0.705 0.015 286.067);
87 | }
88 |
89 | .dark {
90 | --background: oklch(0.141 0.005 285.823);
91 | --foreground: oklch(0.985 0 0);
92 | --card: oklch(0.21 0.006 285.885);
93 | --card-foreground: oklch(0.985 0 0);
94 | --popover: oklch(0.21 0.006 285.885);
95 | --popover-foreground: oklch(0.985 0 0);
96 | --primary: oklch(0.92 0.004 286.32);
97 | --primary-foreground: oklch(0.21 0.006 285.885);
98 | --secondary: oklch(0.274 0.006 286.033);
99 | --secondary-foreground: oklch(0.985 0 0);
100 | --muted: oklch(0.274 0.006 286.033);
101 | --muted-foreground: oklch(0.705 0.015 286.067);
102 | --accent: oklch(0.274 0.006 286.033);
103 | --accent-foreground: oklch(0.985 0 0);
104 | --destructive: oklch(0.704 0.191 22.216);
105 | --border: oklch(1 0 0 / 10%);
106 | --input: oklch(1 0 0 / 15%);
107 | --ring: oklch(0.552 0.016 285.938);
108 | --chart-1: oklch(0.488 0.243 264.376);
109 | --chart-2: oklch(0.696 0.17 162.48);
110 | --chart-3: oklch(0.769 0.188 70.08);
111 | --chart-4: oklch(0.627 0.265 303.9);
112 | --chart-5: oklch(0.645 0.246 16.439);
113 | --sidebar: oklch(0.21 0.006 285.885);
114 | --sidebar-foreground: oklch(0.985 0 0);
115 | --sidebar-primary: oklch(0.488 0.243 264.376);
116 | --sidebar-primary-foreground: oklch(0.985 0 0);
117 | --sidebar-accent: oklch(0.274 0.006 286.033);
118 | --sidebar-accent-foreground: oklch(0.985 0 0);
119 | --sidebar-border: oklch(1 0 0 / 10%);
120 | --sidebar-ring: oklch(0.552 0.016 285.938);
121 | }
--------------------------------------------------------------------------------
/suo5/tunnel.go:
--------------------------------------------------------------------------------
1 | package suo5
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "fmt"
7 | log "github.com/kataras/golog"
8 | "io"
9 | "sync"
10 | "sync/atomic"
11 | "time"
12 | )
13 |
14 | type TunnelConn struct {
15 | id string
16 | once sync.Once
17 | closeMu sync.Mutex
18 | writeMu sync.Mutex
19 | readChan chan map[string][]byte
20 | readBuf bytes.Buffer
21 | remoteWrite IdWriteFunc
22 | config *Suo5Config
23 | lastHaveWrite atomic.Bool
24 | closed atomic.Bool
25 | onClose []func()
26 | ctx context.Context
27 | cancel func()
28 | }
29 |
30 | func NewTunnelConn(id string, config *Suo5Config, remoteWrite IdWriteFunc) *TunnelConn {
31 | ctx, cancel := context.WithCancel(context.Background())
32 | return &TunnelConn{
33 | id: id,
34 | readChan: make(chan map[string][]byte, 32),
35 | remoteWrite: remoteWrite,
36 | config: config,
37 | ctx: ctx,
38 | cancel: cancel,
39 | }
40 | }
41 |
42 | func (s *TunnelConn) ReadUnmarshal() (map[string][]byte, error) {
43 | if s.closed.Load() {
44 | return nil, fmt.Errorf("tunnel %s is closed", s.id)
45 | }
46 | select {
47 | case m, ok := <-s.readChan:
48 | if !ok {
49 | return nil, io.EOF
50 | }
51 | return m, nil
52 | case <-time.After(s.config.TimeoutTime()):
53 | return nil, fmt.Errorf("timeout when read from tunnel %s", s.id)
54 | }
55 | }
56 |
57 | func (s *TunnelConn) AddCloseCallback(fn func()) {
58 | s.onClose = append(s.onClose, fn)
59 | }
60 |
61 | func (s *TunnelConn) RemoteData(m map[string][]byte) {
62 | s.closeMu.Lock()
63 | defer s.closeMu.Unlock()
64 | if s.closed.Load() {
65 | return
66 | }
67 | select {
68 | case s.readChan <- m:
69 | default:
70 | }
71 | }
72 |
73 | func (s *TunnelConn) Read(p []byte) (n int, err error) {
74 | if s.readBuf.Len() != 0 {
75 | return s.readBuf.Read(p)
76 | }
77 | m, ok := <-s.readChan
78 | if !ok {
79 | return 0, io.EOF
80 | }
81 |
82 | action := m["ac"]
83 | if len(action) != 1 {
84 | return 0, fmt.Errorf("invalid action when read %v", action)
85 | }
86 | switch action[0] {
87 | case ActionData:
88 | data := m["dt"]
89 | s.readBuf.Reset()
90 | s.readBuf.Write(data)
91 | return s.readBuf.Read(p)
92 | case ActionDelete:
93 | s.CloseSelf()
94 | return 0, io.EOF
95 | default:
96 | return 0, fmt.Errorf("unpected action when read %v", action)
97 | }
98 | }
99 |
100 | func (s *TunnelConn) Write(p []byte) (int, error) {
101 | partWrite := 0
102 | chunkSize := s.config.MaxBodySize
103 | if len(p) > chunkSize {
104 | log.Debugf("split data to %d chunk, length: %d", len(p)/chunkSize, len(p))
105 | for i := 0; i < len(p); i += chunkSize {
106 | act := NewActionData(s.id, p[i:minInt(i+chunkSize, len(p))])
107 | body := BuildBody(act, s.config.RedirectURL, s.config.SessionId, s.config.Mode)
108 | n, err := s.WriteRaw(body, false)
109 | if err != nil {
110 | return partWrite, err
111 | }
112 | partWrite += n
113 | }
114 | return partWrite, nil
115 | } else {
116 | body := BuildBody(NewActionData(s.id, p), s.config.RedirectURL, s.config.SessionId, s.config.Mode)
117 | return s.WriteRaw(body, false)
118 | }
119 | }
120 |
121 | func (s *TunnelConn) WriteRaw(p []byte, noDelay bool) (n int, err error) {
122 | s.writeMu.Lock()
123 | defer s.writeMu.Unlock()
124 | if s.closed.Load() {
125 | return 0, io.EOF
126 | }
127 | if len(p) == 0 {
128 | return 0, nil
129 | }
130 | s.lastHaveWrite.Store(true)
131 |
132 | err = s.remoteWrite(&IdData{s.id, p, noDelay})
133 | if err != nil {
134 | return 0, err
135 | } else {
136 | return len(p), nil
137 | }
138 | }
139 |
140 | func (s *TunnelConn) CloseSelf() {
141 | s.once.Do(func() {
142 | log.Debugf("closing tunnel byself %s", s.id)
143 | s.cancel()
144 | for _, fn := range s.onClose {
145 | fn()
146 | }
147 |
148 | s.closeMu.Lock()
149 | defer s.closeMu.Unlock()
150 |
151 | close(s.readChan)
152 | s.closed.Store(true)
153 | })
154 | }
155 |
156 | func (s *TunnelConn) Close() error {
157 | s.once.Do(func() {
158 | log.Debugf("closing tunnel %s", s.id)
159 | defer log.Debugf("tunnel closed, %s", s.id)
160 | s.cancel()
161 |
162 | body := BuildBody(NewActionDelete(s.id), s.config.RedirectURL, s.config.SessionId, s.config.Mode)
163 | _, _ = s.WriteRaw(body, false)
164 |
165 | for _, fn := range s.onClose {
166 | fn()
167 | }
168 |
169 | s.closeMu.Lock()
170 | defer s.closeMu.Unlock()
171 |
172 | close(s.readChan)
173 | s.closed.Store(true)
174 | })
175 | return nil
176 | }
177 |
178 | func (s *TunnelConn) SetupActivePoll() {
179 | ticker := time.NewTicker(time.Millisecond * time.Duration(s.config.ClassicPollInterval))
180 | go func() {
181 | defer ticker.Stop()
182 | for {
183 | select {
184 | case <-s.ctx.Done():
185 | return
186 | case <-ticker.C:
187 | if s.lastHaveWrite.Load() {
188 | s.lastHaveWrite.Store(false)
189 | continue
190 | }
191 | _, err := s.Write(nil)
192 | if err != nil {
193 | log.Error(err)
194 | return
195 | }
196 | }
197 | }
198 | }()
199 | }
200 |
201 | func minInt(i int, i2 int) int {
202 | if i < i2 {
203 | return i
204 | }
205 | return i2
206 | }
207 |
--------------------------------------------------------------------------------
/gui/frontend/src/components/ui/command.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { Command as CommandPrimitive } from "cmdk"
5 | import { SearchIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 | import {
9 | Dialog,
10 | DialogContent,
11 | DialogDescription,
12 | DialogHeader,
13 | DialogTitle,
14 | } from "@/components/ui/dialog"
15 |
16 | function Command({
17 | className,
18 | ...props
19 | }: React.ComponentProps) {
20 | return (
21 |
29 | )
30 | }
31 |
32 | function CommandDialog({
33 | title = "Command Palette",
34 | description = "Search for a command to run...",
35 | children,
36 | ...props
37 | }: React.ComponentProps & {
38 | title?: string
39 | description?: string
40 | }) {
41 | return (
42 |
53 | )
54 | }
55 |
56 | function CommandInput({
57 | className,
58 | ...props
59 | }: React.ComponentProps) {
60 | return (
61 |
65 |
66 |
74 |
75 | )
76 | }
77 |
78 | function CommandList({
79 | className,
80 | ...props
81 | }: React.ComponentProps) {
82 | return (
83 |
91 | )
92 | }
93 |
94 | function CommandEmpty({
95 | ...props
96 | }: React.ComponentProps) {
97 | return (
98 |
103 | )
104 | }
105 |
106 | function CommandGroup({
107 | className,
108 | ...props
109 | }: React.ComponentProps) {
110 | return (
111 |
119 | )
120 | }
121 |
122 | function CommandSeparator({
123 | className,
124 | ...props
125 | }: React.ComponentProps) {
126 | return (
127 |
132 | )
133 | }
134 |
135 | function CommandItem({
136 | className,
137 | ...props
138 | }: React.ComponentProps) {
139 | return (
140 |
148 | )
149 | }
150 |
151 | function CommandShortcut({
152 | className,
153 | ...props
154 | }: React.ComponentProps<"span">) {
155 | return (
156 |
164 | )
165 | }
166 |
167 | export {
168 | Command,
169 | CommandDialog,
170 | CommandInput,
171 | CommandList,
172 | CommandEmpty,
173 | CommandGroup,
174 | CommandItem,
175 | CommandShortcut,
176 | CommandSeparator,
177 | }
178 |
--------------------------------------------------------------------------------
/netrans/reader.go:
--------------------------------------------------------------------------------
1 | package netrans
2 |
3 | import (
4 | "bufio"
5 | "bytes"
6 | "context"
7 | "errors"
8 | "io"
9 | "sync"
10 | "time"
11 | )
12 |
13 | var ErrReadTimeout = errors.New("read timeout")
14 | var ErrReaderClosed = errors.New("reader has been closed")
15 |
16 | var errNormal = errors.New("normal read")
17 |
18 | type TimeoutReader struct {
19 | rc io.ReadCloser
20 | buf *bufio.Reader
21 | t time.Duration
22 | errCh chan error
23 | mu sync.Mutex
24 | closed bool
25 | ctx context.Context
26 | cancel func()
27 | }
28 |
29 | func NewTimeoutReader(ctx context.Context, r io.Reader, timeout time.Duration) io.Reader {
30 | return NewTimeoutReadCloser(ctx, io.NopCloser(r), timeout)
31 | }
32 |
33 | func NewTimeoutReadCloser(ctx context.Context, rc io.ReadCloser, timeout time.Duration) io.ReadCloser {
34 | if timeout < 0 {
35 | panic("invalid timeout")
36 | }
37 | ctx, cancel := context.WithCancel(ctx)
38 |
39 | tr := &TimeoutReader{
40 | rc: rc,
41 | buf: bufio.NewReaderSize(rc, 4096),
42 | t: timeout,
43 | ctx: ctx,
44 | cancel: cancel,
45 | }
46 | tr.startLoop()
47 | return tr
48 | }
49 |
50 | func (r *TimeoutReader) startLoop() {
51 | r.errCh = make(chan error)
52 | go func() {
53 | defer close(r.errCh)
54 | for {
55 | _, err := r.buf.Peek(1)
56 | nErr := err
57 | if nErr == nil {
58 | nErr = errNormal
59 | }
60 | select {
61 | case r.errCh <- nErr:
62 | case <-r.ctx.Done():
63 | return
64 | }
65 | if err != nil {
66 | return
67 | }
68 | }
69 | }()
70 | }
71 |
72 | func (r *TimeoutReader) Read(b []byte) (int, error) {
73 | r.mu.Lock()
74 | defer r.mu.Unlock()
75 | if r.closed {
76 | return 0, ErrReaderClosed
77 | }
78 |
79 | reread:
80 | select {
81 | case err := <-r.errCh: // Timeout
82 | if r.buf.Buffered() > 0 {
83 | return r.buf.Read(b)
84 | }
85 | if errors.Is(err, errNormal) {
86 | // 非预期的情况
87 | goto reread
88 | }
89 | if err == nil {
90 | // channel closed
91 | r.closed = true
92 | return 0, ErrReaderClosed
93 | } else {
94 | return 0, err
95 | }
96 | case <-time.After(r.t):
97 | return 0, ErrReadTimeout
98 | }
99 | }
100 |
101 | func (r *TimeoutReader) Close() error {
102 | err := r.rc.Close()
103 | r.cancel()
104 | r.closed = true
105 | return err
106 | }
107 |
108 | type channelReader struct {
109 | ch chan []byte
110 | mu sync.Mutex
111 | buf bytes.Buffer
112 | }
113 |
114 | func NewChannelReader(ch chan []byte) io.Reader {
115 | return &channelReader{ch: ch}
116 | }
117 |
118 | func (c *channelReader) Read(p []byte) (n int, err error) {
119 | c.mu.Lock()
120 | defer c.mu.Unlock()
121 | if c.buf.Len() != 0 {
122 | return c.buf.Read(p)
123 | }
124 | var data []byte
125 | var ok bool
126 | for {
127 | data, ok = <-c.ch
128 | if !ok {
129 | return 0, io.EOF
130 | }
131 | if len(data) != 0 {
132 | break
133 | }
134 | }
135 | c.buf.Reset()
136 | c.buf.Write(data)
137 | return c.buf.Read(p)
138 | }
139 |
140 | type channelWriterCloser struct {
141 | ch chan []byte
142 | mu sync.Mutex
143 | closed bool
144 | ctx context.Context
145 | cancel func()
146 | }
147 |
148 | func NewChannelWriteCloser(ctx context.Context) (chan []byte, io.WriteCloser) {
149 | ch := make(chan []byte, 64)
150 | ctx, cancel := context.WithCancel(ctx)
151 | return ch, &channelWriterCloser{
152 | ch: ch,
153 | ctx: ctx,
154 | cancel: cancel,
155 | }
156 | }
157 |
158 | func (c *channelWriterCloser) Close() error {
159 | c.cancel()
160 |
161 | c.mu.Lock()
162 | defer c.mu.Unlock()
163 |
164 | if !c.closed {
165 | close(c.ch)
166 | c.closed = true
167 | }
168 | return nil
169 | }
170 |
171 | func (c *channelWriterCloser) Write(p []byte) (n int, err error) {
172 | c.mu.Lock()
173 | defer c.mu.Unlock()
174 |
175 | if c.closed {
176 | return 0, io.EOF
177 | }
178 | select {
179 | case c.ch <- p:
180 | return len(p), nil
181 | case <-c.ctx.Done():
182 | return 0, c.ctx.Err()
183 | default:
184 | // don't block on write
185 | return 0, errors.New("write channel is full")
186 | }
187 | }
188 |
189 | type multiReadCloser struct {
190 | rcs []io.ReadCloser
191 | r io.Reader
192 | }
193 |
194 | func MultiReadCloser(rcs ...io.ReadCloser) io.ReadCloser {
195 | var rs []io.Reader
196 | for _, rc := range rcs {
197 | rs = append(rs, rc.(io.Reader))
198 | }
199 | return &multiReadCloser{
200 | rcs: rcs,
201 | r: io.MultiReader(rs...),
202 | }
203 | }
204 |
205 | func (m *multiReadCloser) Read(p []byte) (n int, err error) {
206 | return m.r.Read(p)
207 | }
208 |
209 | func (m *multiReadCloser) Close() error {
210 | var err error
211 | for _, rc := range m.rcs {
212 | if e := rc.Close(); e != nil {
213 | err = e
214 | }
215 | }
216 | return err
217 | }
218 | func NoOpReader(r io.Reader) io.Reader {
219 | return r
220 | }
221 |
222 | // OffsetReader 创建一个从指定偏移量开始读取的 Reader
223 | func OffsetReader(r io.Reader, offset int64) io.Reader {
224 | // 如果 Reader 支持 Seek,直接使用 Seek
225 | if seeker, ok := r.(io.Seeker); ok {
226 | _, err := seeker.Seek(offset, io.SeekStart)
227 | if err == nil {
228 | return r
229 | }
230 | }
231 |
232 | // 否则通过读取来跳过到指定偏移量
233 | _, err := io.CopyN(io.Discard, r, offset)
234 | if err != nil && err != io.EOF {
235 | return &errorReader{err: err}
236 | }
237 | return r
238 | }
239 |
240 | type errorReader struct {
241 | err error
242 | }
243 |
244 | func (e *errorReader) Read(p []byte) (n int, err error) {
245 | return 0, e.err
246 | }
247 |
--------------------------------------------------------------------------------
/gui/build/windows/installer/wails_tools.nsh:
--------------------------------------------------------------------------------
1 | # DO NOT EDIT - Generated automatically by `wails build`
2 |
3 | !include "x64.nsh"
4 | !include "WinVer.nsh"
5 | !include "FileFunc.nsh"
6 |
7 | !ifndef INFO_PROJECTNAME
8 | !define INFO_PROJECTNAME "{{.Name}}"
9 | !endif
10 | !ifndef INFO_COMPANYNAME
11 | !define INFO_COMPANYNAME "{{.Info.CompanyName}}"
12 | !endif
13 | !ifndef INFO_PRODUCTNAME
14 | !define INFO_PRODUCTNAME "{{.Info.ProductName}}"
15 | !endif
16 | !ifndef INFO_PRODUCTVERSION
17 | !define INFO_PRODUCTVERSION "{{.Info.ProductVersion}}"
18 | !endif
19 | !ifndef INFO_COPYRIGHT
20 | !define INFO_COPYRIGHT "{{.Info.Copyright}}"
21 | !endif
22 | !ifndef PRODUCT_EXECUTABLE
23 | !define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
24 | !endif
25 | !ifndef UNINST_KEY_NAME
26 | !define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
27 | !endif
28 | !define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
29 |
30 | !ifndef REQUEST_EXECUTION_LEVEL
31 | !define REQUEST_EXECUTION_LEVEL "admin"
32 | !endif
33 |
34 | RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
35 |
36 | !ifdef ARG_WAILS_AMD64_BINARY
37 | !define SUPPORTS_AMD64
38 | !endif
39 |
40 | !ifdef ARG_WAILS_ARM64_BINARY
41 | !define SUPPORTS_ARM64
42 | !endif
43 |
44 | !ifdef SUPPORTS_AMD64
45 | !ifdef SUPPORTS_ARM64
46 | !define ARCH "amd64_arm64"
47 | !else
48 | !define ARCH "amd64"
49 | !endif
50 | !else
51 | !ifdef SUPPORTS_ARM64
52 | !define ARCH "arm64"
53 | !else
54 | !error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
55 | !endif
56 | !endif
57 |
58 | !macro wails.checkArchitecture
59 | !ifndef WAILS_WIN10_REQUIRED
60 | !define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
61 | !endif
62 |
63 | !ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
64 | !define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
65 | !endif
66 |
67 | ${If} ${AtLeastWin10}
68 | !ifdef SUPPORTS_AMD64
69 | ${if} ${IsNativeAMD64}
70 | Goto ok
71 | ${EndIf}
72 | !endif
73 |
74 | !ifdef SUPPORTS_ARM64
75 | ${if} ${IsNativeARM64}
76 | Goto ok
77 | ${EndIf}
78 | !endif
79 |
80 | IfSilent silentArch notSilentArch
81 | silentArch:
82 | SetErrorLevel 65
83 | Abort
84 | notSilentArch:
85 | MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
86 | Quit
87 | ${else}
88 | IfSilent silentWin notSilentWin
89 | silentWin:
90 | SetErrorLevel 64
91 | Abort
92 | notSilentWin:
93 | MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
94 | Quit
95 | ${EndIf}
96 |
97 | ok:
98 | !macroend
99 |
100 | !macro wails.files
101 | !ifdef SUPPORTS_AMD64
102 | ${if} ${IsNativeAMD64}
103 | File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
104 | ${EndIf}
105 | !endif
106 |
107 | !ifdef SUPPORTS_ARM64
108 | ${if} ${IsNativeARM64}
109 | File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
110 | ${EndIf}
111 | !endif
112 | !macroend
113 |
114 | !macro wails.writeUninstaller
115 | WriteUninstaller "$INSTDIR\uninstall.exe"
116 |
117 | SetRegView 64
118 | WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
119 | WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
120 | WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
121 | WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
122 | WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
123 | WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
124 |
125 | ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
126 | IntFmt $0 "0x%08X" $0
127 | WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
128 | !macroend
129 |
130 | !macro wails.deleteUninstaller
131 | Delete "$INSTDIR\uninstall.exe"
132 |
133 | SetRegView 64
134 | DeleteRegKey HKLM "${UNINST_KEY}"
135 | !macroend
136 |
137 | # Install webview2 by launching the bootstrapper
138 | # See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
139 | !macro wails.webview2runtime
140 | !ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
141 | !define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
142 | !endif
143 |
144 | SetRegView 64
145 | # If the admin key exists and is not empty then webview2 is already installed
146 | ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
147 | ${If} $0 != ""
148 | Goto ok
149 | ${EndIf}
150 |
151 | ${If} ${REQUEST_EXECUTION_LEVEL} == "user"
152 | # If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
153 | ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
154 | ${If} $0 != ""
155 | Goto ok
156 | ${EndIf}
157 | ${EndIf}
158 |
159 | SetDetailsPrint both
160 | DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
161 | SetDetailsPrint listonly
162 |
163 | InitPluginsDir
164 | CreateDirectory "$pluginsdir\webview2bootstrapper"
165 | SetOutPath "$pluginsdir\webview2bootstrapper"
166 | File "tmp\MicrosoftEdgeWebview2Setup.exe"
167 | ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
168 |
169 | SetDetailsPrint both
170 | ok:
171 | !macroend
--------------------------------------------------------------------------------
/gui/app.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
8 | "github.com/zema1/suo5/ctrl"
9 | "github.com/zema1/suo5/suo5"
10 | "math"
11 | "net/url"
12 | "os"
13 | "sync/atomic"
14 | "time"
15 | )
16 |
17 | // App struct
18 | type App struct {
19 | ctx context.Context
20 | cancel func()
21 | cancelSuo5 func()
22 |
23 | connCount int32
24 | speed atomic.Pointer[suo5.SpeedStatisticEvent]
25 | }
26 |
27 | // NewApp creates a new App application struct
28 | func NewApp() *App {
29 | return &App{}
30 | }
31 |
32 | // Startup is called when the app starts. The context is saved
33 | // so we can call the runtime methods
34 | func (a *App) Startup(ctx context.Context) {
35 | ctx, cancel := context.WithCancel(ctx)
36 | a.ctx = ctx
37 | a.cancel = cancel
38 | go func() {
39 | ticker := time.NewTicker(time.Second)
40 | defer ticker.Stop()
41 | for {
42 | select {
43 | case <-ticker.C:
44 | wailsRuntime.EventsEmit(a.ctx, "status", a.GetStatus())
45 | case <-ctx.Done():
46 | return
47 | }
48 | }
49 | }()
50 | }
51 |
52 | func (a *App) RunSuo5WithConfig(config *suo5.Suo5Config) {
53 | cliCtx, cancel := context.WithCancel(a.ctx)
54 | a.cancelSuo5 = cancel
55 | config.OnRemoteConnected = func(e *suo5.ConnectedEvent) {
56 | wailsRuntime.EventsEmit(a.ctx, "connected", e.Mode)
57 | }
58 | config.OnNewClientConnection = func(event *suo5.ClientConnectionEvent) {
59 | atomic.AddInt32(&a.connCount, 1)
60 | }
61 | config.OnClientConnectionClose = func(event *suo5.ClientConnectCloseEvent) {
62 | atomic.AddInt32(&a.connCount, -1)
63 | }
64 | config.OnSpeedInfo = func(event *suo5.SpeedStatisticEvent) {
65 | a.speed.Store(event)
66 | }
67 |
68 | config.GuiLog = &GuiLogger{ctx: a.ctx}
69 | go func() {
70 | defer cancel()
71 | err := ctrl.Run(cliCtx, config)
72 | if err != nil {
73 | fmt.Println(err)
74 | wailsRuntime.EventsEmit(a.ctx, "error", err.Error())
75 | }
76 | }()
77 | }
78 |
79 | func (a *App) DefaultSuo5Config() *suo5.Suo5Config {
80 | return suo5.DefaultSuo5Config()
81 | }
82 |
83 | func (a *App) GetStatus() *RunStatus {
84 | count := atomic.LoadInt32(&a.connCount)
85 | status := &RunStatus{
86 | ConnectionCount: count,
87 | }
88 | speedInfo := a.speed.Load()
89 | if speedInfo != nil {
90 | status.Upload = formatSpeed(float64(speedInfo.Upload))
91 | status.Download = formatSpeed(float64(speedInfo.Download))
92 | } else {
93 | status.Upload = "0b/s"
94 | status.Download = "0b/s"
95 | }
96 | return status
97 | }
98 |
99 | func (a *App) ImportConfig() (*suo5.Suo5Config, error) {
100 | options := wailsRuntime.OpenDialogOptions{
101 | DefaultDirectory: "",
102 | DefaultFilename: "",
103 | Title: "导入 Suo5 配置",
104 | Filters: []wailsRuntime.FileFilter{
105 | {
106 | DisplayName: "json",
107 | Pattern: "*.json",
108 | },
109 | },
110 | }
111 | filepath, err := wailsRuntime.OpenFileDialog(a.ctx, options)
112 | if err != nil {
113 | return nil, err
114 | }
115 | // user canceled
116 | if filepath == "" {
117 | return nil, nil
118 | }
119 | var config suo5.Suo5Config
120 | data, err := os.ReadFile(filepath)
121 | if err != nil {
122 | return nil, err
123 | }
124 | err = json.Unmarshal(data, &config)
125 | if err != nil {
126 | return nil, err
127 | }
128 | if config.Listen == "" {
129 | return nil, fmt.Errorf("invalid config")
130 | }
131 | return &config, nil
132 | }
133 |
134 | func (a *App) ExportConfig(config *suo5.Suo5Config) error {
135 | filename := "suo5-config.json"
136 | if config.Target != "" {
137 | u, err := url.Parse(config.Target)
138 | if err == nil {
139 | filename = u.Hostname() + ".json"
140 | }
141 | }
142 |
143 | options := wailsRuntime.SaveDialogOptions{
144 | DefaultFilename: filename,
145 | Title: "导出 Suo5 配置",
146 | Filters: []wailsRuntime.FileFilter{
147 | {
148 | DisplayName: "json",
149 | Pattern: "*.json",
150 | },
151 | },
152 | }
153 | filepath, err := wailsRuntime.SaveFileDialog(a.ctx, options)
154 | if err != nil {
155 | return err
156 | }
157 | if filepath == "" {
158 | return fmt.Errorf("user canceled")
159 | }
160 | data, err := json.MarshalIndent(config, "", " ")
161 | if err != nil {
162 | return err
163 | }
164 | err = os.WriteFile(filepath, data, 0644)
165 | if err != nil {
166 | return err
167 | }
168 | return nil
169 | }
170 |
171 | func (a *App) Shutdown(_ context.Context) {
172 | if a.cancel != nil {
173 | a.cancel()
174 | }
175 | if a.cancelSuo5 != nil {
176 | a.cancelSuo5()
177 | }
178 | }
179 |
180 | // Stop suo5, for frontend use
181 | func (a *App) Stop() {
182 | if a.cancelSuo5 != nil {
183 | a.cancelSuo5()
184 | }
185 | a.speed.Store(nil)
186 | }
187 |
188 | type RunStatus struct {
189 | ConnectionCount int32 `json:"connection_count"`
190 | Upload string `json:"upload"`
191 | Download string `json:"download"`
192 | }
193 |
194 | type GuiLogger struct {
195 | ctx context.Context
196 | }
197 |
198 | func (g *GuiLogger) Write(p []byte) (n int, err error) {
199 | wailsRuntime.EventsEmit(g.ctx, "log", string(p))
200 | return len(p), nil
201 | }
202 |
203 | // formatSpeed 将以字节/秒为单位的速率格式化为人类可读的字符串。
204 | // 例如:"2b/s", "320kb/s", "3Mb/s"。
205 | // 这里 "b" 代表字节, "k" 代表 1000, "M" 代表 1000*1000。
206 | func formatSpeed(bytesPerSecond float64) string {
207 | if bytesPerSecond < 0 {
208 | bytesPerSecond = 0 // 速度不应为负
209 | }
210 |
211 | // 定义单位和用户请求的后缀
212 | const unit = 1000.0
213 | val := bytesPerSecond
214 | suffix := "b/s" // 默认为 Bytes/second
215 |
216 | if val >= unit { // 大于或等于 1 KB/s
217 | val /= unit
218 | suffix = "kb/s" // KiloBytes/second
219 | if val >= unit { // 大于或等于 1 MB/s (1000 KB/s)
220 | val /= unit
221 | suffix = "Mb/s" // MegaBytes/second
222 | if val >= unit { // 大于或等于 1 GB/s (1000 MB/s)
223 | val /= unit
224 | suffix = "Gb/s" // GigaBytes/second
225 | if val >= unit { // 大于或等于 1 TB/s (1000 GB/s)
226 | val /= unit
227 | suffix = "Tb/s" // TeraBytes/second
228 | // 如果需要,可以添加更多后缀 (Pb/s, Eb/s)
229 | }
230 | }
231 | }
232 | }
233 |
234 | // 格式化输出以匹配用户示例 "320kb/s", "3Mb/s", "2b/s"
235 | // 如果是整数或者是 "b/s" 单位,则显示为整数。
236 | // 否则 (对于 kb/s, Mb/s 等非整数值),使用一位小数。
237 | if suffix == "b/s" {
238 | // 对于 "b/s",总是四舍五入到最近的整数
239 | return fmt.Sprintf("%d%s", int64(math.Round(val)), suffix)
240 | }
241 |
242 | // 对于 kb/s, Mb/s 等
243 | if val == float64(int64(val)) { // 如果值是整数 (例如 320.0)
244 | return fmt.Sprintf("%d%s", int64(val), suffix)
245 | }
246 | return fmt.Sprintf("%.1f%s", val, suffix) // 例如 "320.5kb/s"
247 | }
248 |
--------------------------------------------------------------------------------
|