From 0c6acb25678163933e6a8a32411ae8745b476d04 Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Thu, 7 May 2026 20:31:21 +0800 Subject: [PATCH] feat: add minimize to tray functionality (#711) --- assets/icon.ico | Bin 0 -> 67646 bytes lib/l10n/app_en.arb | 5 + lib/l10n/app_localizations.dart | 30 ++ lib/l10n/app_localizations_en.dart | 16 + lib/l10n/app_localizations_zh.dart | 30 ++ lib/l10n/app_zh.arb | 5 + lib/l10n/app_zh_Hant.arb | 5 + lib/ui/configuration.dart | 10 + lib/ui/desktop/preference.dart | 11 + lib/ui/desktop/request/report_servers.dart | 299 +++++++------ lib/ui/desktop/toolbar/toolbar.dart | 1 + lib/ui/launch/launch.dart | 61 +++ lib/utils/desktop_tray.dart | 91 ++++ linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + macos/Runner/AppDelegate.swift | 2 +- pubspec.yaml | 2 + wiki/上报服务器.md | 396 ------------------ wiki/安卓无ROOT使用Xposed模块抓包.md | 38 -- wiki/抓包原理介绍.md | 222 ---------- wiki/脚本.md | 125 ------ wiki/请求重写.md | 28 -- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 25 files changed, 456 insertions(+), 932 deletions(-) create mode 100644 assets/icon.ico create mode 100644 lib/utils/desktop_tray.dart delete mode 100644 wiki/上报服务器.md delete mode 100644 wiki/安卓无ROOT使用Xposed模块抓包.md delete mode 100644 wiki/抓包原理介绍.md delete mode 100644 wiki/脚本.md delete mode 100644 wiki/请求重写.md diff --git a/assets/icon.ico b/assets/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..21591956c8389ab9c39432074243d0b56940e3de GIT binary patch literal 67646 zcmeHQXOvx6bsjH%5dR53%*tB)2{Hz3z$AbvMmRVL)d3SA2{mic93nd=CKxa_3CJu# zf=vk6vLvf_jjF9~*|LfyYl<}eP1W??%d0aZ**o8N?%Qw9T;2EP&C_URH1A%oqg&rS zdw<{F`?P(>IXO4rzokoa@b99W+y3N+oIlIS$+-;$mw3@!=OK^I{ArFU&iTy!`}*sz z=eWuZIDfN<+=>1D*e^#piV{HSK^aByHwOV?K}?7ZFVF!>UA;+almuR22LbgXx&Y=WR z{B33tAg09j5n>(3Ji)=$d2#x`M*TS~+~=T-j~G3?WSE%W&_GUp6B@9 zALCu8gnNk1vEgF+KRNc=crRYq=5ubx-xd}EoFhuw7f{>4o^!hXyxzj%p4yDZxix-n z#{hC4FI>y<=kpgH_o@-s3vN}u)dqOZ^+Y_xbv%Dt*a$4swPW(Hwm`dp+I0(CbzeJj zOs*~7O`j0gnY-c7^DKN5A;;v}_I>6RF}K9uvIBv0@PFp%sSPk+&(GP)4(_RC5Zp7L zko>dX$6QT+%Ps_%Q>r=I%rW)1>_XrS_-DI+Ckpe7{Vlr?Am{pR26N5*ExQni!T;}v z%FDA$ZC_gp0djxTI`6V}8de45|e$Q;{Fx&O1 zuBt!rATHzM=;#$i@9|kLEcM;jS|_c?*1^_8<($ttTx*Qn@3%2iA0UUy zbu1Q>Xf!I}aM+YkC?xK}dyz=QJjdsWuWbv;ht}nm;cCzIy;_f(bJc+|QdYP8JfCYF zt`>t#eox$N%*YveCtk#gxCDbi86F;%!NEZp7#NWL{{BgY*LZJeXvjRr=ZPh4hVS9~ zG$ybv2KY*(XJV*cXq1cf4W{t8p|Me#aJ{9aRod_z?SsD~-ijgZfWD~h!qxgwG96e4>mqNg zn=()q%0$^HV^edpk-0vu19hPu)Qvh)SJi#%vJ?NS!XMApTl$Dl9 z;mHGX;rZW5<<@_d#&w^UⅇhZ`J!`VCC&7?~(qM@0FfacS!r1yQO}^KS=qGA4=iD z6;gQSC8-Dmj8BmR##QwrjRl$DXX{{d&v#M>*1@`1C;d4PsFcF9FF~efjBFPnV;f|p z%#@va^rQ5ydcSn6{+KkbzgGfVml&N3PaTx9vQlYiXfl`(8)C$IlIf5sH?#3OVn-j8jT%56))_gyly@>U70SR~;UZ$(*Tj(M(y*PMHAmEek-ja{^^ z{iKxV{#Xi6>@yrN?$VDKk69nV7k@xI?P8KLBhFNG&i@ z9o#lx_i3if&$WD>xUs0uu&q?TXRK^%YnMQIsgxf0U+F+>oZwxt$b3(K8(HyoY2NU8 zDL%GAs;jF_ecB$_Hb72OVW|AmR%kP9hZ&3M_w>t}ni?s7ag#J{_z>+Hr`ApF zpkwV{OUc3COQ536=%8_cHmH4?RPtX-j#Irm8E%xH+*gzoNY$=~Wnk6qabKAllj-BS zNP1R(SjzVQRw@JKrjO0`lT^A_Gp7DKW$U{gQ+-~t$WHn?UHey@s@bAAJg96Apvk- zw_}M6uY6|)xJ?gd`VsM~*!v5qC@Yqh)>hNsq%3L!UVfzf6IazkeZLzvTvHv8%I6=) zyZ<5)vuB$7iT5osSk>j;FM-lRY47MXSW-%WA4Dfpw!o3yt(gZc;~y*<31s)wheECJ}5>v>Y&$C#ezi!v$|dXE;W^<21{bf zHdSSx4>7>)`|2}2J-yOUe^F}o{}Q(B^zA3?+pTw;_to!~(9X|E`1nsHQuvHS%ePB3 zuuCE(8zge}za@O|dlK5TIBpM9VF({u@lL7T_AgSBf6|Q8*nVp)b&svopSVx(kM^Rg z19LeC9!*0B=tN#=s~hi>@bll4@TvcT9#2c8c#TBPuawA{WfDGgpM5-#|ogtmXmi4iH=VfxT5(y;k!QhfFZ=Hr@Dj;+W&3;T$s z?-z+mci=@CTJwI?mCD}Y`tA6Pg!BI>(e`2)4fV^|DCVH32ju6TaeFj8B+>3_W7DC% z|CojyERw#}cS!Z=ZKfR{wi*L8c9Da**jO(MG1GRR{gfW`SL!MXrDO9KQpY;R$*q4Q z;nJ-V9qE&?ajyA09PphQkGbdhC31uNPTT<@wTFcV}p}>u=gSgq zK8Lt58t2~W*J`___NDuT9e|^;@yikmp-+5awS?B+k=o9T4J?xOb)S%u7dM*mHD%=Z zkTzhC4{68bpZnP8xCDzfAf~@P9&1$g_@1!~b%%@BNh~~^&_Q`ux$XUA9WG&x1nZx* z#}LmxlhTeyR=h(RUU<^Dl5sM;Upd#1l7m$yXeZxg&uBi0z(^6Gcg|SszV$PVd6I1Fz?pa3R zqmkA^_$S7D?sxMX?R=zshm4x`%E|3<+pevLmkz8a%DIyIQxe>k8nfJH->&MQvDddfG|X6s;LCu|^$zQ#!4ISH*pU&FIK*Yl$TsEbNRaNLR@uh;Mf4#g2<4>s`pCt_`?!8 zdcVZ-ekWty6<6?1nbO@Rjk*ym^nUsj)4AUYydvSe&?mJs9YKi#oMZhoF(Y9;&veswkiSF>9qgCEmmW*r2CRCQv=`*c0DO%;H=p=t->>V%$CJt)Tz$Lr zmG76~5ocX?rrIvWagq%TcT4y*WO?>oN#xP^iv9@VZR6&zO3B%yi8%t!6S>#k7-Q7e zw9ZVq*Wd9yl$%(XJ`}L&E8QnSj%z(^m+Q-2C!T<>Ib(o%#eWa-vGzXzywOK;w_k`| z!<#-Xy^SR^&OfnKK5QF^g@951QxaPHmyl_yvBAw)Km39epL)(&Lr$>`$na(tWA)m=36L|$_Q?l_Pse*7I} zro0LNA0CnJ){8Q*Bgxzuu{mkFT{qz&-N_J|d-u z{(u~>fOyRjXrGXEUXA?xE#-A$sI%TkjCyx|Eh&xz&wWP*dpoaK3!W~1SNlBipRnC1 zWNMeez2AV`-toV8>%G!iUx~J>-^>;2{Ka%Jbn~u!(+-KFU3ks8&iP#ApSPZu(B7|j z_O0wbi*tg5KX~u~85-!t{Mp>k|4;{& ze|WG@1`qw4H~;Xd{`JUxtvGAuuh_TtiUGtW)#m1&b(3$}65}WPM0_vzJWq__X&Wf9 zQN-D zSci3PFWKN*gD?Hung7F_5%teB{-nY=oqKA-)IryNhfXa6|6Z74{&Lm3rT&#op7|__ zpXy>`sGMtk8s~{S>m_zv6Xlql7;;UAd!5Up9neM+v4HbLm?H?i@L+sx$4g#q51d?_ z+U?($l7iFmIfAM42yB@ZOTDIkPn{_f<#T)=`BEKS(r{)Iu)?_2gMa3l4xU>sLG-uT zrYUZG_iS%AAEWRqGk;aGLxQW4%>TeHn)m*|&_ej@_*eq z+R1cK&J`;+_mqqB(Pk-kV?%>9@59`cr#zUez`Vdv;Gn^p`Y2v=#Xsv+PPm>k+)^aL zjfh=IayT`VG-0&TgQ|-#(0&-*RK3udKYx(#d!E5Yd*#LQIU;DSn0pDo8W#AgJ>Oh&u zCuN*U2gWVx%(z7zm?K?-y!DHFA2#!mc3(7cO#TnuFQMW7gf1E*m5u;+SdGjjL3JKH4W;@@sl%o;NCFv1l()6OI4Uj0!iIrKE@t~4^(I;b6} z4zvf=gSt^i+9ca2+74}~4tYuC$5ta>?tR|A&v<}#YNTXqV%?l}JlFiw-X_O@F$wk5 zOE5Rd_{I3rBI#bU7~g8Fm+G2YtiMit$Eoc$IVMJ0cuu^OcVfnGY%B0hN%?cjq#t9o zE55~m4GgZmL%J(p2L8@k9(`UhQ@M$wu>s`kIe7%1o-z(hj&%5ihaU4V6ifvQqJmM+y zx4qi2xD9lzxl4);EsuZy*l4gIhQx(95+fEKv+z3OMKiGG_teGb^Q3&w&rQBR`AEb` zD_#eYZ!l1>M#7NE-Cv^Iw(P`|W5h@^^9a72LUyNL$U4op2D=|MvQQ?KTbHsx(wj=eFD;8w`;Eh#v@+sH;4se{T+u332A)YK&P zu#*749X)$ON_PK3x}W`UYFmTvhxUC%!u>5KCv86YzKx;U0C8sST)6(kl)0_+A%45p zx)!;aJ0Fw0W4TiB>S-x1Dw5K&3MnfO0Ml|Qx^Q0d&%TWB={HOH_8*$>E){<-zc<&2 z+lCJte|B<72@ zbw!{G`jpH0!hFd)v0pCi{k8cHoZl!Ui(LsF)_+jKbuXB?E8B)u_W80`{+;%e>n`Z` zFh7_1{hs4NCyr82w%xRY?q@$J4eP%km0RwU((ONzk{v&hGGJP@`PcgYrD^05INvc-w%%1AfDZRc1^%8vaqgpXVb@YA z+Vue<_nBmNYy3@753_Fv`;@3tJ1Z2)5{uEC%AwKE== z!Uh~WnD{=P=NvDpeK`F)$Jcl*X`$a^%|BeY+ZZ>3wUEf!ww|(@!vd=JzeJ;jZh7dmBT>DdR)z zSL53)ehYW)@B?PxKD_M{60P2k`4Oy*q25e=#%%+pUmL?3Q{`SOk02dCo~A8m?0WTo z;P<^I@6^_Ta$PT*9S4{r&+q!8wTC3U3%M;^d!w$l-Di5t_t0Ms-6yfO^QQlZOC{D*W8yt?jkTZ0`mg8Bjo+oq$Qi(QGoziQ62;m}7~gz{*1QL{5a*Qk zU<%K*=ryh@Aa>w8necO8mngoi9vx^jcE$JFv2rFc^VEU1jJk~-?8jUOze6;CtAMt^ zqB48wfNLC|?D#Zz|CNlkpO*X@CsC{;MUMX%w)s`zH)2K)$j@&G!&~o0+jp--j(p$PeXRDNj14py zUmzB!}1z4Kg0=hL42f z-(gJPH5kP;q!Ij*H^sU__iIu$nJ;sSeyv$WJ~Ud8ea8w(#y{+cu8r9(%oKF0Z94xBc8) z%InwdBj6+8Bj6+8Bj6+8Bj6+8Bj6+8Bj6+8Bj6+8Bj6+8Bj6*j@Dad#Tf_VB;~qab`boS=OSGJ&G?Q*W_F`wFT@M1FEtIimcPLiX1E7 z%0KtK1~j;T2>bD@LuTPCJRaxPaBXw1{KxI$CO~m`;lq)28Xg9}H^n)2({Ij!h_MYk zvlz$CS%=WVRk)d)l6!KTY~wvXgTGzJ{Susq7cL}Oqgj|-HZ?UFZpm?~jr_;Ujd=D+ zl*?Iz)WTDg+&u|CZ&Y4Waj*CES?t*t*n;BE3tfM$8-Is)X@7ui z0s8}fpAaC-(~aD)y*Du*$A-I1xYysfY=Hd;jtiRSss7n4Rx`OlF@3$6;9hM&-$h%9 zk0Urgz&Rp+o)93+@kS0fx3f4tZZ|%UYf9gY?a9AY7-KkB?5`KhodIw@{~?q_AD{eO z%Z+x$!g}I+t{HLNunxr^BLIYJyb)t!y$l=>`vey}o~@1iYl*KXQWoYFFptR3B?AaE zyb)7k`v`g0HC*Ljws?2fqxEwS*=Q5YD`Gw&b4r<4%sgZN`vQP4&5f85J7UOOP3G${ zPcPwjICs-AXXli~RhVNyd6{F*Z%me>97PGB^q~0P9Rq~;Ml6U4u^~pwm_@ zZUW<(B3k6s#H-$bnkO|OIK4b^J16IEkzxD%O&AH<=Wi5QYM&#e$Ue=<5tqgOa?%usmb@>;67hE`Th;=^U1gUGamsT0UrS$f$If 'Automatically start recording traffic when the program starts'; + @override + String get minimizeToTrayTitle => 'Minimize to tray on close'; + + @override + String get minimizeToTraySubtitle => 'Closing the window will keep ProxyPin running and hide it to the system tray.'; + + @override + String get trayClosePromptContent => + 'Closing the window will keep ProxyPin running in the system tray. Do you want to minimize it now?'; + + @override + String get trayCloseExitAnyway => 'Exit anyway'; + + @override + String get trayCloseMinimizeToTray => 'Minimize to tray'; + @override String get copied => 'Copied to clipboard'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index e81cb03..812bc60 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -140,6 +140,21 @@ class AppLocalizationsZh extends AppLocalizations { @override String get autoStartupDescribe => '程序启动时自动开始记录流量'; + @override + String get minimizeToTrayTitle => '关闭时最小化到系统托盘'; + + @override + String get minimizeToTraySubtitle => '点击窗口关闭按钮时不退出程序,而是隐藏到系统托盘图标。'; + + @override + String get trayClosePromptContent => '关闭窗口后程序不会退出,而是继续在系统托盘中运行。要继续最小化到托盘吗?'; + + @override + String get trayCloseExitAnyway => '直接退出'; + + @override + String get trayCloseMinimizeToTray => '最小化到托盘'; + @override String get copied => '已复制到剪切板'; @@ -1216,6 +1231,21 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get autoStartupDescribe => '程式啟動時自動開始記錄流量'; + @override + String get minimizeToTrayTitle => '關閉時最小化到系統托盤'; + + @override + String get minimizeToTraySubtitle => '點擊視窗關閉按鈕時不退出程式,而是隱藏到系統托盤圖示。'; + + @override + String get trayClosePromptContent => '關閉視窗後程式不會退出,而是繼續在系統托盤中執行。要繼續最小化到托盤嗎?'; + + @override + String get trayCloseExitAnyway => '直接退出'; + + @override + String get trayCloseMinimizeToTray => '最小化到托盤'; + @override String get copied => '已複製到剪切板'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index ba30ed4..3843611 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -46,6 +46,11 @@ "language": "语言", "autoStartup": "自动开启抓包", "autoStartupDescribe": "程序启动时自动开始记录流量", + "minimizeToTrayTitle": "关闭时最小化到系统托盘", + "minimizeToTraySubtitle": "点击窗口关闭按钮时不退出程序,而是隐藏到系统托盘图标。", + "trayClosePromptContent": "关闭窗口后程序不会退出,而是继续在系统托盘中运行。要继续最小化到托盘吗?", + "trayCloseExitAnyway": "直接退出", + "trayCloseMinimizeToTray": "最小化到托盘", "copied": "已复制到剪切板", "execute": "执行", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 9b94aff..8080d7a 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -43,6 +43,11 @@ "language": "語言", "autoStartup": "自動開啟抓包", "autoStartupDescribe": "程式啟動時自動開始記錄流量", + "minimizeToTrayTitle": "關閉時最小化到系統托盤", + "minimizeToTraySubtitle": "點擊視窗關閉按鈕時不退出程式,而是隱藏到系統托盤圖示。", + "trayClosePromptContent": "關閉視窗後程式不會退出,而是繼續在系統托盤中執行。要繼續最小化到托盤嗎?", + "trayCloseExitAnyway": "直接退出", + "trayCloseMinimizeToTray": "最小化到托盤", "copied": "已複製到剪切板", "execute": "執行", "cancel": "取消", diff --git a/lib/ui/configuration.dart b/lib/ui/configuration.dart index 6964bdc..702f16d 100644 --- a/lib/ui/configuration.dart +++ b/lib/ui/configuration.dart @@ -103,6 +103,12 @@ class AppConfiguration { //左侧面板占比 double panelRatio = 0.3; + /// 关闭窗口时最小化到系统托盘 + bool minimizeToTray = false; + + /// 首次关闭时是否已经提示过最小化到托盘 + bool? minimizeToTrayPromptShown; + AppConfiguration._(); /// 单例 @@ -222,6 +228,8 @@ class AppConfiguration { if (config['panelRatio'] != null) { panelRatio = config['panelRatio']; } + minimizeToTray = config['minimizeToTray'] ?? false; + minimizeToTrayPromptShown = config['minimizeToTrayPromptShown']; } catch (e) { logger.e(e); } @@ -266,6 +274,8 @@ class AppConfiguration { if (Platforms.isDesktop()) "windowPosition": windowPosition == null ? null : {"dx": windowPosition?.dx, "dy": windowPosition?.dy}, if (Platforms.isDesktop()) 'panelRatio': panelRatio, + if (Platforms.isDesktop()) 'minimizeToTray': minimizeToTray, + if (Platforms.isDesktop() && minimizeToTrayPromptShown != null) 'minimizeToTrayPromptShown': minimizeToTrayPromptShown, }; } } diff --git a/lib/ui/desktop/preference.dart b/lib/ui/desktop/preference.dart index b092000..25ffd57 100644 --- a/lib/ui/desktop/preference.dart +++ b/lib/ui/desktop/preference.dart @@ -121,6 +121,17 @@ class _PreferenceState extends State { ]), themeColor(context), const Divider(), + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(localizations.minimizeToTrayTitle, style: titleStyle), + subtitle: Text(localizations.minimizeToTraySubtitle, style: subtitleStyle), + trailing: SwitchWidget( + scale: 0.75, + value: appConfiguration.minimizeToTray, + onChanged: (value) { + appConfiguration.minimizeToTray = value; + appConfiguration.flushConfig(); + })), ListTile( contentPadding: EdgeInsets.zero, title: Text(localizations.autoStartup, style: titleStyle), diff --git a/lib/ui/desktop/request/report_servers.dart b/lib/ui/desktop/request/report_servers.dart index 6d2f71e..ced6bce 100644 --- a/lib/ui/desktop/request/report_servers.dart +++ b/lib/ui/desktop/request/report_servers.dart @@ -65,6 +65,7 @@ class _ReportServersPageState extends State { Future _load() async { final manager = await ReportServerManager.instance; final list = manager.servers; + if (!mounted) return; setState(() { _servers = List.of(list); _loading = false; @@ -75,6 +76,38 @@ class _ReportServersPageState extends State { return OutlineInputBorder(borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)); } + InputDecoration _inputDecoration({String? hint, String? helper}) => InputDecoration( + hintText: hint, + helperText: helper, + hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 14), + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), + errorStyle: const TextStyle(height: 0, fontSize: 0), + focusedBorder: focusedBorder(), + isDense: true, + border: const OutlineInputBorder(), + ); + + Widget _buildLabel(String label, Widget field, {bool expanded = true}) => Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox(width: AppLocalizations.of(context)!.localeName == 'en' ? 108 : 88, child: Text(label)), + const SizedBox(width: 12), + expanded ? Expanded(child: field) : field, + ], + ); + + Widget _buildDialogSection({required Widget child}) { + final scheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: scheme.outlineVariant.withValues(alpha: 0.35)), + ), + child: child, + ); + } + // 统一的新增/编辑弹窗 Future _showServerDialog({ReportServer? initial}) async { final nameCtrl = TextEditingController(text: initial?.name ?? ''); @@ -84,137 +117,160 @@ class _ReportServersPageState extends State { bool enabled = initial?.enabled ?? true; bool splitReport = initial?.splitReport ?? false; - // 紧凑的 Outline 输入框装饰 - InputDecoration dec({String? hint}) => InputDecoration( - hintText: hint, - hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 14), - contentPadding: const EdgeInsets.symmetric(horizontal: 5, vertical: 12), - errorStyle: const TextStyle(height: 0, fontSize: 0), - focusedBorder: focusedBorder(), - isDense: true, - border: const OutlineInputBorder()); - - Widget labeled(String label, Widget field, {bool expanded = true}) => Row( - children: [ - SizedBox(width: AppLocalizations.of(context)!.localeName == 'en' ? 95 : 85, child: Text(label)), - const SizedBox(width: 12), - expanded ? Expanded(child: field) : field, - ], - ); - final formKey = GlobalKey(); - - final result = await showDialog( - context: context, - builder: (ctx) { - return AlertDialog( - title: Text(initial == null ? localizations.addReportServer : localizations.editReportServer, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w500)), - content: Form( + try { + final result = await showDialog( + context: context, + builder: (ctx) { + return AlertDialog( + insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + titlePadding: const EdgeInsets.fromLTRB(24, 22, 24, 0), + contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 12), + actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + initial == null ? localizations.addReportServer : localizations.editReportServer, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + ], + ), + content: Form( key: formKey, child: SizedBox( - width: 460, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - labeled( - '${localizations.name}: ', - TextField(controller: nameCtrl, decoration: dec(hint: localizations.pleaseEnter)), - ), - const SizedBox(height: 12), - labeled( - '${localizations.match} URL: ', - TextFormField( - controller: matchUrlCtrl, - keyboardType: TextInputType.url, - validator: (val) => val?.isNotEmpty == true ? null : "", - decoration: dec(hint: 'https://example.com/api/*')), - ), - const SizedBox(height: 12), - labeled( - '${localizations.serverUrl}: ', - TextFormField( - controller: serverUrlCtrl, - keyboardType: TextInputType.url, - validator: (val) => val?.isNotEmpty == true ? null : "", - decoration: dec(hint: 'http://example.com/report')), - ), - const SizedBox(height: 12), - labeled( - '${localizations.compression}: ', - expanded: false, - SizedBox( - width: 100, - child: DropdownButtonFormField( - value: compression, - decoration: dec(), - isDense: true, - items: [ - DropdownMenuItem(value: 'none', child: Text(localizations.compressionNone)), - DropdownMenuItem(value: 'gzip', child: Text("GZIP")), - ], - onChanged: (v) => compression = v ?? 'none', - ), - )), - const SizedBox(height: 12), - labeled( - '${localizations.enable}: ', - Align( - alignment: Alignment.centerLeft, - child: SwitchWidget(value: enabled, scale: 0.83, onChanged: (v) => enabled = v), + width: 520, + child: Scrollbar( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildDialogSection( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildLabel( + '${localizations.name}: ', + TextField( + controller: nameCtrl, + decoration: _inputDecoration(hint: localizations.pleaseEnter), + ), + ), + const SizedBox(height: 12), + _buildLabel( + '${localizations.match} URL: ', + TextFormField( + controller: matchUrlCtrl, + keyboardType: TextInputType.url, + validator: (val) => val?.isNotEmpty == true ? null : '', + decoration: _inputDecoration(hint: 'https://example.com/api/*'), + ), + ), + const SizedBox(height: 12), + _buildLabel( + '${localizations.serverUrl}: ', + TextFormField( + controller: serverUrlCtrl, + keyboardType: TextInputType.url, + validator: (val) => val?.isNotEmpty == true ? null : '', + decoration: _inputDecoration(hint: 'http://example.com/report'), + ), + ), + ], + ), ), - ), - const SizedBox(height: 12), - labeled( - '${localizations.splitReport}: ', - Align( - alignment: Alignment.centerLeft, - child: SwitchWidget(value: splitReport, scale: 0.83, onChanged: (v) => splitReport = v), + const SizedBox(height: 12), + _buildDialogSection( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildLabel( + '${localizations.compression}: ', + expanded: false, + SizedBox( + width: 150, + child: DropdownButtonFormField( + initialValue: compression, + decoration: _inputDecoration(), + isDense: true, + items: [ + DropdownMenuItem(value: 'none', child: Text(localizations.compressionNone)), + const DropdownMenuItem(value: 'gzip', child: Text('GZIP')), + ], + onChanged: (v) => compression = v ?? 'none', + ), + ), + ), + const SizedBox(height: 12), + _buildLabel( + '${localizations.enable}: ', + Align( + alignment: Alignment.centerLeft, + child: SwitchWidget(value: enabled, scale: 0.83, onChanged: (v) => enabled = v), + ), + ), + const SizedBox(height: 12), + _buildLabel( + '${localizations.splitReport}: ', + Align( + alignment: Alignment.centerLeft, + child: + SwitchWidget(value: splitReport, scale: 0.83, onChanged: (v) => splitReport = v), + ), + ), + ], + ), ), - ), - ], + ], + ), ), ), - )), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, null), - child: Text(localizations.cancel), + ), ), - FilledButton( - onPressed: () { - if (!(formKey.currentState as FormState).validate()) { - FlutterToastr.show("${localizations.serverUrl} ${localizations.cannotBeEmpty}", context, - position: FlutterToastr.top); - return; - } + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, null), + child: Text(localizations.cancel), + ), + FilledButton( + onPressed: () { + if (!(formKey.currentState as FormState).validate()) { + FlutterToastr.show("${localizations.serverUrl} ${localizations.cannotBeEmpty}", context, + position: FlutterToastr.top); + return; + } - final matchUrl = matchUrlCtrl.text.trim(); - var serverUrl = serverUrlCtrl.text.trim(); - // 修复此前的前缀判断逻辑:仅当不以 http/https 开头时补全 - if (!serverUrl.startsWith('http://') && !serverUrl.startsWith('https://')) { - serverUrl = 'http://$serverUrl'; - } + final matchUrl = matchUrlCtrl.text.trim(); + var serverUrl = serverUrlCtrl.text.trim(); + if (!serverUrl.startsWith('http://') && !serverUrl.startsWith('https://')) { + serverUrl = 'http://$serverUrl'; + } - final server = ReportServer( - name: nameCtrl.text.trim(), - matchUrl: matchUrl, - serverUrl: serverUrl, - enabled: enabled, - compression: compression, - splitReport: splitReport, - ); - Navigator.pop(ctx, server); - }, - child: Text(localizations.save), - ), - ], - ); - }, - ); + final server = ReportServer( + name: nameCtrl.text.trim(), + matchUrl: matchUrl, + serverUrl: serverUrl, + enabled: enabled, + compression: compression, + splitReport: splitReport, + ); + Navigator.pop(ctx, server); + }, + child: Text(localizations.save), + ), + ], + ); + }, + ); - return result; + return result; + } finally { + nameCtrl.dispose(); + matchUrlCtrl.dispose(); + serverUrlCtrl.dispose(); + } } Future _addServerDialog() async { @@ -253,7 +309,6 @@ class _ReportServersPageState extends State { title: Text(localizations.reportServers), centerTitle: true, actions: [ - TextButton.icon( label: Text(localizations.newBuilt), onPressed: _addServerDialog, @@ -263,7 +318,7 @@ class _ReportServersPageState extends State { IconButton( tooltip: localizations.useGuide, onPressed: _openGuide, - icon: const Icon(Icons.help_outline,size: 21), + icon: const Icon(Icons.help_outline, size: 21), ), IconButton( tooltip: localizations.close, diff --git a/lib/ui/desktop/toolbar/toolbar.dart b/lib/ui/desktop/toolbar/toolbar.dart index f254640..85a2a03 100644 --- a/lib/ui/desktop/toolbar/toolbar.dart +++ b/lib/ui/desktop/toolbar/toolbar.dart @@ -66,6 +66,7 @@ class _ToolbarState extends State { if (HardwareKeyboard.instance.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyQ) { windowManager.close(); + windowManager.destroy(); return true; } diff --git a/lib/ui/launch/launch.dart b/lib/ui/launch/launch.dart index 0d91740..8d16c85 100644 --- a/lib/ui/launch/launch.dart +++ b/lib/ui/launch/launch.dart @@ -24,7 +24,9 @@ import 'package:proxypin/native/vpn.dart'; import 'package:proxypin/network/bin/server.dart'; import 'package:proxypin/network/util/logger.dart'; import 'package:proxypin/ui/desktop/ssl/pc_cert.dart'; +import 'package:proxypin/ui/configuration.dart'; import 'package:proxypin/utils/lang.dart'; +import 'package:proxypin/utils/desktop_tray.dart'; import 'package:proxypin/utils/platform.dart'; import 'package:window_manager/window_manager.dart'; @@ -67,6 +69,7 @@ class _SocketLaunchState extends State with WindowListener, Widget if (Platforms.isDesktop()) { windowManager.addListener(this); windowManager.setPreventClose(true); + DesktopTrayManager.instance.setQuitHandler(appExit); } WidgetsBinding.instance.addObserver(this); @@ -89,20 +92,78 @@ class _SocketLaunchState extends State with WindowListener, Widget void dispose() { windowManager.removeListener(this); WidgetsBinding.instance.removeObserver(this); + if (Platforms.isDesktop()) { + DesktopTrayManager.instance.setQuitHandler(null); + } super.dispose(); } @override void onWindowClose() async { logger.d("onWindowClose"); + await _handleWindowClose(); + } + + Future _handleWindowClose() async { + final appConfiguration = AppConfiguration.current; + if (Platforms.isDesktop() && appConfiguration?.minimizeToTray == true) { + if (appConfiguration?.minimizeToTrayPromptShown == null) { + final minimize = await _showTrayClosePrompt(); + if (!mounted) { + return; + } + + appConfiguration?.minimizeToTrayPromptShown = true; + await appConfiguration?.flushConfig(); + + if (!minimize) { + await appExit(); + return; + } + } + + try { + await DesktopTrayManager.instance.showToTray(); + return; + } catch (e) { + logger.e('show to tray failed, fallback to exit', error: e); + } + } await appExit(); } + Future _showTrayClosePrompt() async { + return await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) { + return AlertDialog( + title: Text(localizations.minimizeToTrayTitle), + content: SizedBox( + width: 320, + child: Text(maxLines: 3, localizations.trayClosePromptContent)), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text(localizations.trayCloseExitAnyway), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: Text(localizations.trayCloseMinimizeToTray), + ), + ], + ); + }, + ) ?? + false; + } + Future appExit() async { logger.d("appExit"); await widget.proxyServer.stop(); started = false; if (Platforms.isDesktop()) { + await DesktopTrayManager.instance.exitApp(); windowManager.setPreventClose(false); await windowManager.destroy(); } diff --git a/lib/utils/desktop_tray.dart b/lib/utils/desktop_tray.dart new file mode 100644 index 0000000..c5990aa --- /dev/null +++ b/lib/utils/desktop_tray.dart @@ -0,0 +1,91 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:menu_base/menu_base.dart'; +import 'package:tray_manager/tray_manager.dart'; +import 'package:window_manager/window_manager.dart'; + +class DesktopTrayManager with TrayListener { + static final DesktopTrayManager instance = DesktopTrayManager._(); + + DesktopTrayManager._(); + + bool _initialized = false; + Future Function()? _quitHandler; + + void setQuitHandler(Future Function()? handler) { + _quitHandler = handler; + } + + String _text(String zh, String en) => Platform.localeName.startsWith('zh') ? zh : en; + + Future ensureInitialized() async { + if (_initialized) { + return; + } + + _initialized = true; + trayManager.addListener(this); + await trayManager.setIcon('assets/icon.ico'); + await trayManager.setToolTip('ProxyPin'); + await trayManager.setContextMenu( + Menu( + items: [ + MenuItem( + key: 'show_window', + label: _text('显示窗口', 'Show window'), + ), + MenuItem.separator(), + MenuItem( + key: 'quit_app', + label: _text('退出', 'Quit'), + ), + ], + ), + ); + } + + Future showToTray() async { + await ensureInitialized(); + await windowManager.hide(); + } + + Future restoreWindow() async { + await windowManager.show(); + await windowManager.focus(); + } + + Future exitApp() async { + try { + trayManager.removeListener(this); + await trayManager.destroy(); + } catch (_) { + // ignore tray cleanup errors during app shutdown + } finally { + _initialized = false; + } + } + + @override + void onTrayIconMouseDown() { + unawaited(restoreWindow()); + } + + @override + void onTrayIconRightMouseDown() { + unawaited(trayManager.popUpContextMenu()); + } + + @override + void onTrayMenuItemClick(MenuItem menuItem) { + switch (menuItem.key) { + case 'show_window': + unawaited(restoreWindow()); + break; + case 'quit_app': + unawaited(_quitHandler?.call() ?? exitApp()); + break; + } + } +} + diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 6a2f742..c5dddc5 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -31,6 +32,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); + g_autoptr(FlPluginRegistrar) tray_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "TrayManagerPlugin"); + tray_manager_plugin_register_with_registrar(tray_manager_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index cc9ca78..f6f9b7e 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_js proxy_manager screen_retriever_linux + tray_manager url_launcher_linux window_manager zstandard_linux diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index dbdc78c..9c99250 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -14,6 +14,7 @@ import proxy_manager import screen_retriever_macos import share_plus import shared_preferences_foundation +import tray_manager import url_launcher_macos import window_manager import zstandard_macos @@ -28,6 +29,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) ZstandardMacosPlugin.register(with: registry.registrar(forPlugin: "ZstandardMacosPlugin")) diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index 8a88ce1..2bd9b15 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -5,7 +5,7 @@ import FlutterMacOS class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return true + return false } override func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { diff --git a/pubspec.yaml b/pubspec.yaml index 8996b20..37e4f64 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,6 +50,7 @@ dependencies: win32audio: ^1.3.1 vclibs: ^0.1.3 scrollable_positioned_list_nic: ^0.0.2 + tray_manager: ^0.5.2 dev_dependencies: flutter_test: @@ -64,5 +65,6 @@ flutter: - assets/certs/ca.crt - assets/certs/ca_key.pem - assets/icon.png + - assets/icon.ico - assets/icon_foreground.png - assets/js/ \ No newline at end of file diff --git a/wiki/上报服务器.md b/wiki/上报服务器.md deleted file mode 100644 index b506abb..0000000 --- a/wiki/上报服务器.md +++ /dev/null @@ -1,396 +0,0 @@ -## 上报服务使用说明 - -## 概述 -本说明描述如何接受并处理来自 ProxyPin 的单条请求上报。上报通常以 JSON 格式 POST 到你的 HTTP 服务。上报格式使用 单个 HAR Entry 方式。即每次上报仅包含一个 HAR entry 对象,结构类似于: - -```json -{ - "startedDateTime": "2025-10-25T12:34:56.789Z", - "time": 123, - "request": { /* 请求部分 */ }, - "response": { /* 响应部分 */ }, - "timings": { /* timing 信息 */ } -} -``` - -服务端只需接收该 entry(可以选择直接保存 entry 或将其包装为标准 HAR 的 `log` 对象再保存)。下面示例中服务器会将收到的 entry 包装为带单条 entry 的 HAR log 并保存成 JSON 文件,方便用 HAR 工具(DevTools / Charles / mitmproxy)打开或导入。 - -> 说明:客户端会使用 `Content-Type: application/json`格式请求;如果客户端启用了压缩(gzip),请求会有 `Content-Encoding: gzip`,服务端需先解压再解析 JSON。ProxyPin除了会向服务器发送请求和响应数据,还会在请求头中携带下面这些数据: - -X-Report-Name:命中的上报的规则名称 - - - -## 请求 & 响应 字段详解(示例) - -下面给出更完整的 `request` 与 `response` 字段示例(放在单条 entry 内),以及每个字段的说明。你可以直接把这些结构当作后端解析与保存的参考。 - -### 请求字段示例(request) - -```json -"request": { - "method": "POST", - "url": "https://api.example.com/v1/upload?user=1", //完整请求 URL(包含 scheme、主机、路径与查询字符串)。 - "httpVersion": "HTTP/1.1", - "cookies": [], - "headers": [ //一个数组,每项包含 name/value(HAR 标准) - { "name": "Host", "value": "api.example.com" }, - { "name": "Content-Type", "value": "application/json; charset=utf-8" }, - { "name": "Authorization", "value": "Bearer abcdef" } - ], - "queryString": [ //查询参数数组(name/value)。 - { "name": "user", "value": "1" } - ], - "headersSize": -1, - "bodySize": 1234, // 原始 body 大小(字节),若未知可用 -1。 - "postData": { //若有请求体,包含 mimeType 与 text, - "mimeType": "application/json", - "text": "{\"name\":\"Alice\",\"age\":30}", - "params": [] - } -} -``` - -### 响应字段示例(response) - -```json -"response": { - "status": 200, - "statusText": "OK", - "httpVersion": "HTTP/1.1", - "cookies": [], - "headers": [ - { "name": "Content-Type", "value": "application/json; charset=utf-8" }, - { "name": "Content-Encoding", "value": "gzip" } - ], - "content": { - "size": 2048, //响应正文的原始大小(字节)。 - "mimeType": "application/json", - "text": "{\"result\":\"ok\",\"id\":123}", - }, - "redirectURL": "", - "headersSize": -1, - "bodySize": 2048 -} -``` - -## Python (Flask) 服务端示例 - -该最小示例:解压(如需)、解析 JSON、验证为单个 HAR entry,然后把它包装为一个标准 HAR log 并保存到 `received_har/`。 - -1) 环境准备: - -```bash -python3 -m venv venv -source venv/bin/activate -pip install flask -``` - -2) 保存为 `report_server.py`: - -```python -# report_server.py -import os -import gzip -import json -from datetime import datetime -from flask import Flask, request, jsonify - -app = Flask(__name__) -OUT_DIR = 'received_har' -os.makedirs(OUT_DIR, exist_ok=True) - - -def try_decompress(body: bytes, encoding: str): - if not encoding: - return body - enc = encoding.lower() - if 'gzip' in enc: - try: - return gzip.decompress(body) - except Exception as e: - raise RuntimeError(f'gzip decompress error: {e}') - return body - - -@app.route('/report', methods=['POST']) -def report(): - try: - raw = request.get_data() - content_encoding = request.headers.get('Content-Encoding', '') or '' - - # 解压(若有) - try: - body_bytes = try_decompress(raw, content_encoding) - except Exception as e: - return jsonify({'ok': False, 'error': f'decompress failed: {e}'}), 400 - - # 解析 JSON - try: - payload = json.loads(body_bytes.decode('utf-8', errors='replace')) - except Exception as e: - return jsonify({'ok': False, 'error': f'json decode failed: {e}'}), 400 - - # 验证为单个 HAR entry(必须包含 request 或 response) - if not isinstance(payload, dict) or ('request' not in payload and 'response' not in payload): - return jsonify({'ok': False, 'error': 'expected a single HAR entry with request or response field'}), 400 - - # 将 entry 包装为标准 HAR log,便于后续导出或兼容工具 - har = { - 'log': { - 'version': '1.2', - 'creator': {'name': 'ProxyPin-report-server', 'version': '1.0'}, - 'entries': [payload] - } - } - - # persist to file - ts = datetime.utcnow().strftime('%Y%m%dT%H%M%S%f')[:-3] - filename = os.path.join(OUT_DIR, f'har_{ts}.har') - with open(filename, 'w', encoding='utf-8') as f: - json.dump(har, f, ensure_ascii=False, indent=2) - - return jsonify({'ok': True, 'saved': filename}), 200 - - except Exception as e: - return jsonify({'ok': False, 'error': str(e)}), 500 - - -if __name__ == '__main__': - app.run(host='0.0.0.0', port=8080) -``` - -3) 启动服务: - -```bash -python report_server.py -``` - -## curl 测试示例(单条 entry) - -假设你有 `entry.json` 文件,内容为单个 HAR entry(如上所示): - -未压缩上传: - -```bash -curl -X POST http://localhost:8080/report \ - -H "Content-Type: application/json" \ - --data-binary @entry.json -``` - -GZIP 压缩上传: - -```bash -gzip -c entry.json > entry.json.gz - -curl -X POST http://localhost:8080/report \ - -H "Content-Type: application/json" \ - -H "Content-Encoding: gzip" \ - --data-binary @entry.json.gz -``` - -快速 inline 测试(最小 entry): - -```bash -echo '{"startedDateTime":"2025-10-25T12:34:56.789Z","time":1,"request":{},"response":{},"timings":{}}' \ - | curl -X POST http://localhost:8080/report -H "Content-Type: application/json" -d @- -``` - ---- - -## 分离上报模式 - -### 概述 - -默认情况下,ProxyPin 在收到完整响应后才会上报(合并模式)。这存在两个问题: - -1. 如果对方服务器没有返回响应(超时、连接断开等),该请求永远不会被上报 -2. 如果响应很慢,上报会被延迟,尽管请求早已被拦截 - -开启"分离上报"后,request 和 response 会分两次独立上报: -- **Request 阶段**:请求被拦截时立即上报,不等待响应 -- **Response 阶段**:收到响应后单独上报响应数据 - -两次上报通过 `_id` 字段关联。 - -### 配置 - -在上报服务器编辑页面中,开启"分离上报"(Split Report)开关即可。 - -### 请求头区别 - -分离模式下,HTTP 请求会额外携带 `X-Report-Phase` header: - -| Header | 值 | 含义 | -|--------|------|------| -| `X-Report-Phase` | `request` | 分离模式 - 请求阶段上报 | -| `X-Report-Phase` | `response` | 分离模式 - 响应阶段上报 | -| 无此 header | — | 合并模式(默认) | - -### Request 阶段 Payload - -```json -{ - "startedDateTime": "2025-10-25T12:34:56.789Z", - "time": -1, - "pageref": "ProxyPin", - "_id": "mo84rrf7", - "_phase": "request", - "_app": { "name": "com.example.app" }, - "request": { - "method": "POST", - "url": "https://api.example.com/v1/upload?user=1", - "httpVersion": "HTTP/1.1", - "cookies": [], - "headers": [ - { "name": "Host", "value": "api.example.com" }, - { "name": "Content-Type", "value": "application/json" } - ], - "queryString": [ - { "name": "user", "value": "1" } - ], - "postData": { - "mimeType": "application/json", - "text": "{\"name\":\"Alice\"}" - }, - "headersSize": -1, - "bodySize": 16 - }, - "response": null, - "cache": {}, - "timings": { "send": 0, "wait": -1, "receive": 0 }, - "serverIPAddress": "" -} -``` - -关键特征: -- `_phase` 为 `"request"` -- `response` 为 `null` -- `time` 为 `-1`(尚无响应耗时) -- `request` 包含完整的请求信息(headers、body、queryString 等) - -### Response 阶段 Payload - -```json -{ - "startedDateTime": "2025-10-25T12:34:56.789Z", - "time": 150, - "pageref": "ProxyPin", - "_id": "mo84rrf7", - "_phase": "response", - "_app": { "name": "com.example.app" }, - "request": { - "method": "POST", - "url": "https://api.example.com/v1/upload?user=1" - }, - "response": { - "status": 200, - "statusText": "OK", - "httpVersion": "HTTP/1.1", - "cookies": [], - "headers": [ - { "name": "Content-Type", "value": "application/json" } - ], - "content": { - "size": 23, - "mimeType": "application/json", - "text": "{\"result\":\"ok\",\"id\":123}" - }, - "redirectURL": "", - "headersSize": -1, - "bodySize": 23 - }, - "cache": {}, - "timings": { "send": 0, "wait": 150, "receive": 0 }, - "serverIPAddress": "93.184.216.34" -} -``` - -关键特征: -- `_phase` 为 `"response"` -- `request` 仅包含 `method` 和 `url`(避免重复传输完整请求体) -- `response` 包含完整的响应信息 -- 通过 `_id` 与对应的 request 阶段上报关联 - -### 关联逻辑 - -接收服务器通过 `_id` 字段将同一请求的两次上报关联起来。`_id` 是 ProxyPin 为每个请求生成的唯一标识符。 - -### Python 服务端示例(支持分离模式) - -```python -# report_server_split.py -import os -import gzip -import json -from datetime import datetime -from flask import Flask, request, jsonify - -app = Flask(__name__) -OUT_DIR = 'received_har' -os.makedirs(OUT_DIR, exist_ok=True) - -# 暂存 request 阶段数据,等待 response 关联 -pending_requests = {} - - -@app.route('/report', methods=['POST']) -def report(): - raw = request.get_data() - encoding = request.headers.get('Content-Encoding', '') or '' - if 'gzip' in encoding.lower(): - raw = gzip.decompress(raw) - - payload = json.loads(raw.decode('utf-8')) - phase = request.headers.get('X-Report-Phase', '') - request_id = payload.get('_id', '') - report_name = request.headers.get('X-Report-Name', '') - - if phase == 'request': - # 暂存,等待 response 到达后合并 - pending_requests[request_id] = payload - print(f'[REQUEST] id={request_id} {payload["request"]["method"]} {payload["request"]["url"]}') - - elif phase == 'response': - # 取出对应的 request 数据并合并 - req_data = pending_requests.pop(request_id, None) - if req_data: - # 合并为完整 entry - merged = req_data.copy() - merged['response'] = payload['response'] - merged['time'] = payload.get('time', -1) - merged['timings'] = payload.get('timings', {}) - merged['serverIPAddress'] = payload.get('serverIPAddress', '') - del merged['_phase'] - save_entry(merged, request_id) - else: - # 没有对应的 request(可能已超时清理),单独保存 response - save_entry(payload, request_id) - status = payload.get('response', {}).get('status', '?') - print(f'[RESPONSE] id={request_id} status={status}') - - else: - # 合并模式,直接保存 - save_entry(payload, request_id) - print(f'[COMBINED] id={request_id}') - - return jsonify({'ok': True}), 200 - - -def save_entry(entry, request_id): - har = { - 'log': { - 'version': '1.2', - 'creator': {'name': 'ProxyPin-report-server', 'version': '1.0'}, - 'entries': [entry] - } - } - ts = datetime.utcnow().strftime('%Y%m%dT%H%M%S%f')[:-3] - filename = os.path.join(OUT_DIR, f'har_{ts}_{request_id}.har') - with open(filename, 'w', encoding='utf-8') as f: - json.dump(har, f, ensure_ascii=False, indent=2) - - -if __name__ == '__main__': - app.run(host='0.0.0.0', port=8080) -``` diff --git a/wiki/安卓无ROOT使用Xposed模块抓包.md b/wiki/安卓无ROOT使用Xposed模块抓包.md deleted file mode 100644 index 4f59694..0000000 --- a/wiki/安卓无ROOT使用Xposed模块抓包.md +++ /dev/null @@ -1,38 +0,0 @@ -非root用户可以使用Xposed JustTrustMe模块来抓包,支持安卓14,我测试大部分都可以抓到。 - -### 第一步 下载伏羲 伏羲可以使应用加载本机已安装的Xposed模块 - -官网:https://www.die.lu/ -下载 svxp64_xx.apk -Github下载: https://github.com/Katana-Official/SPatch-Update/releases -网盘下载(提取码2035): https://url38.ctfile.com/d/15037138-28988502-8fce96 - -安装完应用就是伏羲x64 - -![输入图片说明](https://foruda.gitee.com/images/1701624799399669180/d50b51a7_1073801.png "屏幕截图") - - -### 第二步 安装JustTrustMe模块 必须使用伏羲从文件管理器安装 -JustTrustMe是一个禁用SSL证书检查的Xposed模块 - -Giuhub下载: https://github.com/Fuzion24/JustTrustMe/releases -网盘下载(提取码8902): https://url37.ctfile.com/f/50805637-984778183-a2b7a3?p=8902 - -打开伏羲应用,点击右下角菜单栏 选择从文件管理器安装,从文件夹选择下载好的JustTrustMe.apk -![输入图片说明](https://foruda.gitee.com/images/1701656562516770260/bbd844b2_1073801.png "屏幕截图") - -安装成功后可以从模块作用管理器看到 -![输入图片说明](https://foruda.gitee.com/images/1701625567175643670/79162358_1073801.png "屏幕截图") - -### 第三步 克隆要抓包的应用 -从伏羲应用点击右下角菜单栏,选择添加(安装)克隆软件 -选择要抓包的软件,点击右下角1,选择克隆数量完成。 - -![ -](https://foruda.gitee.com/images/1701625701181960615/c0d5d643_1073801.png "屏幕截图") - -### 最终 开始抓包 -打开抓包软件ProxyPin,然后从伏羲打开刚才克隆的应用,就可以开始抓包了,我测试大部分都可以抓,如有有些域名全部都是SSL握手失败,为了不影响应用使用,可以在ProxyPin将域名加入域名黑名单 - -![输入图片说明](https://foruda.gitee.com/images/1701627522386866284/98d67136_1073801.png "屏幕截图") - diff --git a/wiki/抓包原理介绍.md b/wiki/抓包原理介绍.md deleted file mode 100644 index 6f82b87..0000000 --- a/wiki/抓包原理介绍.md +++ /dev/null @@ -1,222 +0,0 @@ -### 开源免费抓包ProxyPin, 支持全平台系统。 - -Github: https://github.com/wanghongenpin/proxypin - -### 运行原理 -[ProxyPin](https://github.com/wanghongenpin/proxypin)运行机制是在启动了一个本地代理服务器(默认监听端口9099)来接收网络请求,当客户端与代理服务器进行通信时,ProxyPin会使用自签名SSL证书进行客户端SSL握手通信。为了保证客户端与中间人成功进行SSL握手通信,需要将ProxyPin的根证书安装到系统里。 - - -### 拦截流量 - -![代理服务](https://foruda.gitee.com/images/1730284031075649398/ff72e723_1073801.png "nr2TTX8wb1fWIKkQ6kMLf-transformed.png") - - **桌面端:** -    抓包软件会把系统网络代理设置为代理服务器的地址和端口,比如浏览器等其他客户端发送网络请求时,会将流量转发到代理监听的端口上,从而被ProxyPin代理服务器捕获。但是有些软件并不会读取系统代理信息,可以借助于其他软件(如Proxifier)将流量转ProxyPin。 - -    由于系统代理地址只能设置一个,如果你开启了其他VPN软件,那么就会冲突,ProxyPin提供了外部代理配置,可以将外部代理配置成VPN监听的端口,这样ProxyPin会把流量转发到其他VPN软件,从而实现访问全球网站。 -![系统代理](https://foruda.gitee.com/images/1730284240894481686/763436f9_1073801.png "屏幕截图") - - **手机端:** -第一种配置Wi-Fi代理: -你可以将Wi-Fi代理配置成代理服务器的地址和端口,手机和电脑需要在同一个局域网下,这样系统会将所有流量转发给代理服务器,但是Flutter应用发起的请求不会走代理 - -第二种使用VPN服务: -VpnService会创建一个虚拟网卡,然后通过NAT,将所有的数据包转发到TUN虚拟网卡上去. -VPN程序读取该虚拟网卡设备上的数据,可以获得所有转发到TUN虚拟网络设备上的IP包。 -应用程序会解析IP数据包,获取TCP/UDP数据, 如果是UDP会原文转发到实际的目标地址, TCP数据会判断是否是HTTP(S)协议包, 如果是将会转发到ProxyPin代理服务, 从而实现数据拦截处理. - -![输入图片说明](https://foruda.gitee.com/images/1730303961922584513/c867aecc_1073801.png "屏幕截图") - -### HTTPS协议 -HTTP协议是明文传出, 可以直接解析HTTP报文. - - ![输入图片说明](https://foruda.gitee.com/images/1730308497901486158/5312b6b8_1073801.png "屏幕截图") - -**什么是HTTPS?** -超文本传输协议安全(HTTPS)是HTTP的安全版本。HTTPS在HTTP协议的基础上使用TLS/SSL加密进行通信,以提高数据传输的安全性。 - - **TLS和SSL之间有什么区别?** -Netscape(网景)开发了名为安全套接字层(Secure Socket Layer,SSL)的加密协议,用于在网络上的两个设备之间创建安全连接. -但是,SSL是一种较老的技术,包含一些安全漏洞。传输层安全性协议(TLS)是SSL 的升级版本,用于修复现有SSL漏洞。TLS1.0版实际上最初作为SSL3.1版开发,但在发布前更改了名称,以表明它不再与Netscape关联。由于这个历史原因,TLS和SSL这两个术语有时会互换使用。 -现在使用的都是TLS协议,目前常用版本TLS1.2或较新TLS1.3。 - - **TLS/SSL如何工作?** - -HTTPS为了兼顾安全与效率,同时使用了 对称加密 和 非对称加密。在TLS/SSL握手阶段,使用非对称加密来保证对称加密密钥在传输过程中的安全性,在 HTTPS 连接建立后,会使用对称加密算法对实际传输的数据。因为通常对称加密算法速度更快。 - -![输入图片说明](https://foruda.gitee.com/images/1730344822864001832/68b0f8f7_1073801.png "屏幕截图") - -1. 客户端发送Client Hello - 该消息包含客户端支持的 TLS 版本,支持的加密算法套件,以及一串称为“客户端随机数(client random)”的随机字节。 -2. 服务端回应Server Hello - 服务器接收到客户端的请求后,会选择一个双方都支持的 TLS/SSL 版本和加密算法套件,并将其包含在响应消息中发送回客户端。同时,服务器还会发送自己的数字证书,这个证书包含了服务器的身份信息、公钥等。 -3. 证书校验、密钥交换 - 客户端收到服务器的响应后,会验证服务器的证书。这包括检查证书的颁发机构是否可信(通常通过预安装在系统里或客户端的受信任的根证书列表进行验证)。如果证书验证不通过,客户端会向用户发出警告,可能会中断连接。 - 如果证书验证通过,客户端会使用证书中的公钥加密随机生成的会话密钥(非对称加密),并将其发送给服务器。 -4. 加密通信 - 服务器使用私钥解密得到会话密钥后,双方就可以使用这个会话密钥进行对称加密通信。对称加密算法速度快,适合加密大量的数据。 - -### 解密数据 -从TLS握手可知,我们如果想解密数据,可以在收到TLS Client Hello包时,响应给客户端自签名的证书,这样后续就可以用自签名证书解密密钥信息。但是由于我们自签名证书不是受信任机构签发的,所以需要把根证书安装到系统受信任根证书里。 - -### 根证书安装 -如果你想拦截电脑流量需要在电脑上安装根证书,拦截手机需要在手机端安装证书 - -**Windows根证书安装** -点击安装证书或者双击证书文件进行安装,选择“**受信任的根证书颁发机构**” - - -**Mac根证书安装** -点击安装证书或将证书文件拖拽到钥匙串系统证书里,安装完双击选择“**始终信任此证书**” - - - **iOS根证书安装** -下载手机端[ProxyPin](https://apps.apple.com/app/proxypin/id6450932949), 在HTTPS代理菜单下载根证书或者通过配置WI-FI代理连接电脑访问进行下载证书。下载完整证书需要信任证书。 - -**安装证书 设置 > 已下载描述文件 > 安装** -**信任证书 设置 > 通用 > 关于本机 > 证书信任设置** - - - **安卓根证书安装** - -Android7(不包括)以下,用户目录证书权限和系统目录证书权限是一样的,直接安装证书即可 -Android7之后,Android系统禁用了用户目录证书的权限,用户目录的证书默认不再作为可信任证书,apk需要在network_security_config.xml配置中信任用户证书 - - - -所以Android7之后大部分应用需要把证书安装到系统下,才可以进行抓包。但是安装到系统里需要ROOT,您也可以使用MuMu等模拟器。 - -系统版本 < 14, 安装到系统证书目录,需要安装到/system/ect/security/cacerts -系统版本 >= 14, 安装到系统证书目录,需要安装到/apex/com.android.conscrypt/cacerts - -**Magisk模块:** -安卓ROOT设备可以使用[Magisk ProxyPinCA](https://github.com/wanghongenpin/Magisk-ProxyPinCA/releases)系统证书模块, 安装完重启手机后 在系统证书查看是否有ProxyPinCA证书,如果有说明证书安装成功。 -[您也可以使用frida绕过安卓ssl pinning](https://httptoolkit.com/blog/frida-certificate-pinning/) - -无ROOT可以使用[Xposed模块抓包](https://gitee.com/wanghongenpin/proxypin/wikis/%E5%AE%89%E5%8D%93%E6%97%A0ROOT%E4%BD%BF%E7%94%A8Xposed%E6%A8%A1%E5%9D%97%E6%8A%93%E5%8C%85),只能hook原生请求,flutter等请求无法处理。 - - -### 请求调试 -捕获到流量之后,可以使用调试功能进行数据模拟等各种测试。ProxyPin提供了非常强大的调试功能,主要有重写、断点和脚本功能。 - -#### [请求重写 ](https://github.com/wanghongenpin/proxypin/wiki/%E8%AF%B7%E6%B1%82%E9%87%8D%E5%86%99) -可通过重写规则来修改请求和响应内容。目前重写支持5种类型,分别是替换请求、替换响应、修改请求、修改响应和重定向。 - - **替换请求** -表示整体替换请求数据,支持替换的部分包括:请求方法、请求路径、请求头和请求体。 - - **替换响应** -此重写行为表示整体替换响应数据,支持替换的部分:状态码方法、响应头、响应体。 - - - **修改请求** -相比于替换请求行为,修改请求提供了更加细致化的修改策略。例如删除查询参数,正则替换请求体的内容。 - -举个例子。有下面这样的请求参数,我们希望修改name的值为345,但保留其他的参数不变。 -https://example.com?name=123&age=32 - - - -**修改响应** -基本操作同上面修改请求。 - -#### [脚本](https://github.com/wanghongenpin/proxypin/wiki/%E8%84%9A%E6%9C%AC) - ProxyPin提供了一个脚本功能,开发人员可以编写JS代码以灵活的方式操作请求/响应 - - -#### API说明 - - **function onRequest(context, request)** - -在请求到达服务器之前,调用此函数,您可以在此处修改请求数据
-具体参数格式见 > 参数定义 -```javascript -async function onRequest(context, request) { - console.log(request.url); - //URL参数 - request.queries["name"] = "value"; - //更新或添加新标头 - request.headers["X-New-Headers"] = "My-Value"; - delete request.headers["Key-Need-Delete"]; - - //Update Body 使用fetch API请求接口,具体文档可网上搜索fetch API - //request.body = await fetch('https://www.baidu.com/').then(response => response.text()); - - //共享参数 后面onResponse时取出 - context["name"] = "hello"; - return request; -} -``` -请求中断 -如果需要中断此请求,onRequest函数结果返回null即可! - -**function onResponse(context, request, response)** - -在将响应数据发送到客户端之前,调用此函数,您可以在此处修改响应数据 -```javascript -async function onResponse(context, request, response) { - // 更新或添加新标头 - // response.headers["Name"] = context["name"]; - - // Update status Code - response.statusCode = 500; - - //var body = JSON.parse(response.body); - //body['key'] = "value"; - //response.body = JSON.stringify(body); - return response; -} -``` -#### 参数定义 -```json -//context -{ - "os": "macos", - "scriptName": "Your Script Name", - "deviceId": "设备id", - "session": {} //运行时会话对象,不同请求可传递参数 -} - -//request -{ - "method": " HTTP Method. 示例: GET, POST, ...", - "host": " *只读* 域名. Ex: www.baidu.com, localhost, ...", - "path": ": URL Path. Ex: /v1/api", - "queries": "> JS字典对象URL参数", - "headers": "> JS字典对象所有Header键值", - "body": " 请求体字符串类型 json格式转换对象需要调用JSON.parse(request.body)", -} - -//response -{ - "statusCode": 200, - "headers": "> JS字典对象包含所有Header键值", - "body": " 请求体字符串类型 json格式转换对象需要调用JSON.parse(request.body)", -} -``` - -### JS内置方法 - -##### MD5 -```javascript -var hash = md5('value') //output 2063c1608d6e0baf80249c42e2be5804 -``` - -##### File 文件操作 -API和dart一致,见dart File文档 https://api.dart.ac.cn/stable/3.4.4/dart-io/File-class.html - -```javascript -//File(path):创建一个表示文件的对象,参数 path 是文件的路径。 -var file = File('file.path'); - -//异步读取文件为字符串 -var text = await file.readAsString(); -//异步将字符串内容写入文件。 -await file.writeAsString('text'); -``` - **Fetch API使用参考文档** - -https://developer.mozilla.org/zh-CN/docs/Web/API/Fetch_API/Using_Fetch
-https://www.ruanyifeng.com/blog/2020/12/fetch-tutorial.html - - diff --git a/wiki/脚本.md b/wiki/脚本.md deleted file mode 100644 index 24ab010..0000000 --- a/wiki/脚本.md +++ /dev/null @@ -1,125 +0,0 @@ - **ProxyPin提供了一个脚本功能,开发人员可以编写JS代码以灵活的方式操作请求/响应** - - - -#### API说明 - - **function onRequest(context, request)** -在请求到达服务器之前,调用此函数,您可以在此处修改请求数据 -具体参数格式见 > 参数定义 -```javascript -async function onRequest(context, request) { - console.log(request.url); - //URL参数 - request.queries["name"] = "value"; - //更新或添加新标头 - request.headers["X-New-Headers"] = "My-Value"; - delete request.headers["Key-Need-Delete"]; - - // Update Body 使用fetch API请求接口,具体文档可网上搜索fetch API - //request.body = await fetch('https://www.baidu.com/').then(response => response.text()); - - //共享参数 后面onResponse时取出 - context["name"] = "hello"; - return request; -} -``` -请求中断 -如果需要中断此请求,onRequest函数结果返回null即可! - - -**function onResponse(context, request, response)** -在将响应数据发送到客户端之前,调用此函数,您可以在此处修改响应数据 -```javascript -async function onResponse(context, request, response) { - // 更新或添加新标头 - // response.headers["Name"] = context["name"]; - - // Update status Code - response.statusCode = 500; - - //var body = JSON.parse(response.body); - //body['key'] = "value"; - //response.body = JSON.stringify(body); - return response; -} -``` -#### 参数定义 -```json -//context -{ - "os": "macos", - "scriptName": "Your Script Name", - "deviceId": "设备id", - "session": {} //运行时会话对象,不同请求可传递参数 -} - -//request -{ - "method": " HTTP Method. 示例: GET, POST, ...", - "host": " 域名. Ex: www.baidu.com, localhost, ...", - "path": ": URL Path. Ex: /v1/api", - "queries": "> JS对象({})包含URL参数", - "headers": "> JS对象({})包含Header键值", - "body": " 请求体字符串类型 json格式转换对象需要调用JSON.parse(request.body)", - "rawBody": [] //原始字节数组 Uint8Array -} - -//response -{ - "statusCode": 200, - "headers": "> JS对象({})包含Header键值", - "body": " 请求体字符串类型 json格式转换对象需要调用JSON.parse(request.body)", -} -``` - -### JS内置方法 - -##### MD5 -```javascript -var hash = md5('value') //output 2063c1608d6e0baf80249c42e2be5804 -``` - -##### File 文件操作 -API和dart一致,见dart File文档 https://api.dart.ac.cn/stable/3.4.4/dart-io/File-class.html - -```javascript -//File(path):创建一个表示文件的对象,参数 path 是文件的路径。 -var file = File('file.path'); - -//异步读取文件为字符串 -var text = await file.readAsString(); -//异步将字符串内容写入文件。 -await file.writeAsString('text'); - -``` -* File类的构造函数: -File(path):创建一个表示文件的对象,参数 path 是文件的路径。 - -**所有方法都有同步版本,推荐都使用异步版本,同步方法多了个Sync后缀 比如readAsString(), 同步方法就是readAsStringSync()** -* 读取文件内容的方法: -readAsString():异步读取文件内容并返回一个字符串。通常结合 async/await 使用。 -readAsStringSync():同步读取文件内容并返回一个字符串 (不推荐使用) -readAsBytes():异步读取文件内容并返回一个字节列表。 -readAsBytesSync(): 对应同步方法 - -* 写入文件内容的方法: -writeAsString(content, append):异步将字符串内容写入文件。 append true表示追加. -writeAsBytes(bytes):异步将字节列表内容写入文件。 - -* 文件属性相关方法: -exists():异步检查文件是否存在,返回一个布尔值。 -length():异步获取文件的大小,以字节为单位,返回一个整数。 - -* 文件操作方法: -rename(newPath):重命名文件为指定路径。 - -* 目录操作相关方法(如果 File 对象表示一个目录): -create(recursive: bool):创建目录,如果 recursive 为 true,则可以创建多级目录. - - - **Fetch API使用参考文档** -https://developer.mozilla.org/zh-CN/docs/Web/API/Fetch_API/Using_Fetch -https://www.ruanyifeng.com/blog/2020/12/fetch-tutorial.html - - diff --git a/wiki/请求重写.md b/wiki/请求重写.md deleted file mode 100644 index d56795e..0000000 --- a/wiki/请求重写.md +++ /dev/null @@ -1,28 +0,0 @@ -**可通过重写规则来修改请求和响应内容。目前重写支持5种类型,分别是替换请求、替换响应、修改请求、修改响应和重定向。** - -![输入图片说明](https://foruda.gitee.com/images/1702537566186559610/61939e46_1073801.png "屏幕截图") - -### 创建规则 - 可通过设置菜单里进入请求重写,在请求重写窗口新建规则,也可以通过抓包详情消息体 点击重写icon快速创建重写规则。 - -![输入图片说明](https://foruda.gitee.com/images/1702537617901147388/a0eec569_1073801.png "屏幕截图") - -### 替换响应 - 此重写行为表示整体替换响应数据,支持替换的部分:状态码方法、响应头、响应体。 - -### 修改请求 -相比于替换请求行为,修改请求提供了更加细致化的修改策略。例如删除查询参数,正则替换请求体的内容。 - -举个例子。有下面这样的请求参数,我们希望修改name的值为345,但保留其他的参数不变。 -https://example.com?name=123&age=32 - -![输入图片说明](https://foruda.gitee.com/images/1702537640098491489/93ab2d27_1073801.png "屏幕截图") - -如果复杂一点,我们不知道key的具体值,但是仍然希望替换为345,可以使用正则表达式: - -![输入图片说明](https://foruda.gitee.com/images/1702537648939364690/c2a11428_1073801.png "屏幕截图") - -一个请求可以创建并应用多个修改规则,我们可以在列表中进行管理。 - -![输入图片说明](https://foruda.gitee.com/images/1702537656514234899/c2aceb06_1073801.png "屏幕截图") - diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 76364a6..3b8d770 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -34,6 +35,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); + TrayManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("TrayManagerPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); VclibsPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 8301e9d..63f7c77 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST proxy_manager screen_retriever_windows share_plus + tray_manager url_launcher_windows vclibs win32audio