Refactor endpoint in started interface
This commit is contained in:
+2
-2
@@ -61,8 +61,8 @@ func NewRemoteClient(options RemoteClientOptions) (*grpc.ClientConn, error) {
|
||||
}
|
||||
return grpc.NewClient(target,
|
||||
grpc.WithTransportCredentials(transportCredentials),
|
||||
grpc.WithUnaryInterceptor(NewClientAuthUnaryInterceptor(options.Secret)),
|
||||
grpc.WithStreamInterceptor(NewClientAuthStreamInterceptor(options.Secret)),
|
||||
grpc.WithChainUnaryInterceptor(UnaryClientLocaleInterceptor, NewClientAuthUnaryInterceptor(options.Secret)),
|
||||
grpc.WithChainStreamInterceptor(StreamClientLocaleInterceptor, NewClientAuthStreamInterceptor(options.Secret)),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/sagernet/sing-box/experimental/locale"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
func contextWithLocale(ctx context.Context) context.Context {
|
||||
requestMetadata, loaded := metadata.FromIncomingContext(ctx)
|
||||
if !loaded {
|
||||
return ctx
|
||||
}
|
||||
for _, localeID := range requestMetadata.Get("accept-language") {
|
||||
localizedContext, matched := locale.ContextWithLocale(ctx, localeID)
|
||||
if matched {
|
||||
return localizedContext
|
||||
}
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func UnaryLocaleInterceptor(ctx context.Context, request any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||
return handler(contextWithLocale(ctx), request)
|
||||
}
|
||||
|
||||
func StreamLocaleInterceptor(server any, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
|
||||
return handler(server, &localeServerStream{
|
||||
ServerStream: stream,
|
||||
ctx: contextWithLocale(stream.Context()),
|
||||
})
|
||||
}
|
||||
|
||||
func UnaryClientLocaleInterceptor(ctx context.Context, method string, request, reply any, connection *grpc.ClientConn, invoker grpc.UnaryInvoker, options ...grpc.CallOption) error {
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "accept-language", locale.FromContext(ctx).Locale)
|
||||
return invoker(ctx, method, request, reply, connection, options...)
|
||||
}
|
||||
|
||||
func StreamClientLocaleInterceptor(ctx context.Context, description *grpc.StreamDesc, connection *grpc.ClientConn, method string, streamer grpc.Streamer, options ...grpc.CallOption) (grpc.ClientStream, error) {
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "accept-language", locale.FromContext(ctx).Locale)
|
||||
return streamer(ctx, description, connection, method, options...)
|
||||
}
|
||||
|
||||
type localeServerStream struct {
|
||||
grpc.ServerStream
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (s *localeServerStream) Context() context.Context {
|
||||
return s.ctx
|
||||
}
|
||||
+2
-2
@@ -15,8 +15,8 @@ import (
|
||||
|
||||
func NewServer(startedService *StartedService, secret string) *grpc.Server {
|
||||
server := grpc.NewServer(
|
||||
grpc.ChainUnaryInterceptor(newUnaryAuthInterceptor(secret)),
|
||||
grpc.ChainStreamInterceptor(newStreamAuthInterceptor(secret)),
|
||||
grpc.ChainUnaryInterceptor(newUnaryAuthInterceptor(secret), UnaryLocaleInterceptor),
|
||||
grpc.ChainStreamInterceptor(newStreamAuthInterceptor(secret), StreamLocaleInterceptor),
|
||||
)
|
||||
healthServer := health.NewServer()
|
||||
RegisterStartedServiceServer(server, startedService)
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/sagernet/sing-box/common/urltest"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/experimental/deprecated"
|
||||
"github.com/sagernet/sing-box/experimental/locale"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/protocol/group"
|
||||
"github.com/sagernet/sing/common"
|
||||
@@ -1410,6 +1411,7 @@ func (s *StartedService) SubscribeTailscaleStatus(
|
||||
|
||||
var tags []string
|
||||
statuses := make(map[string]*adapter.TailscaleEndpointStatus, len(endpoints))
|
||||
selectedLocale := locale.FromContext(server.Context())
|
||||
for update := range updates {
|
||||
if _, exists := statuses[update.tag]; !exists {
|
||||
tags = append(tags, update.tag)
|
||||
@@ -1417,7 +1419,7 @@ func (s *StartedService) SubscribeTailscaleStatus(
|
||||
statuses[update.tag] = update.status
|
||||
protoEndpoints := make([]*TailscaleEndpointStatus, 0, len(statuses))
|
||||
for _, tag := range tags {
|
||||
protoEndpoints = append(protoEndpoints, tailscaleEndpointStatusToProto(tag, statuses[tag]))
|
||||
protoEndpoints = append(protoEndpoints, tailscaleEndpointStatusToProto(tag, statuses[tag], selectedLocale))
|
||||
}
|
||||
sendErr := server.Send(&TailscaleStatusUpdate{
|
||||
Endpoints: protoEndpoints,
|
||||
@@ -1429,7 +1431,7 @@ func (s *StartedService) SubscribeTailscaleStatus(
|
||||
return nil
|
||||
}
|
||||
|
||||
func tailscaleEndpointStatusToProto(tag string, s *adapter.TailscaleEndpointStatus) *TailscaleEndpointStatus {
|
||||
func tailscaleEndpointStatusToProto(tag string, s *adapter.TailscaleEndpointStatus, selectedLocale *locale.Locale) *TailscaleEndpointStatus {
|
||||
userGroups := make([]*TailscaleUserGroup, len(s.UserGroups))
|
||||
for i, group := range s.UserGroups {
|
||||
peers := make([]*TailscalePeer, len(group.Peers))
|
||||
@@ -1447,6 +1449,7 @@ func tailscaleEndpointStatusToProto(tag string, s *adapter.TailscaleEndpointStat
|
||||
result := &TailscaleEndpointStatus{
|
||||
EndpointTag: tag,
|
||||
BackendState: s.BackendState,
|
||||
StateText: selectedLocale.TailscaleStateText(s.BackendState),
|
||||
AuthURL: s.AuthURL,
|
||||
NetworkName: s.NetworkName,
|
||||
MagicDNSSuffix: s.MagicDNSSuffix,
|
||||
@@ -1599,19 +1602,21 @@ func (s *StartedService) SubscribeOpenConnectStatus(
|
||||
s.serviceAccess.RUnlock()
|
||||
|
||||
endpointManager := service.FromContext[adapter.EndpointManager](boxService.ctx)
|
||||
selectedLocale := locale.FromContext(server.Context())
|
||||
return subscribeEndpointStatus(server.Context(), endpointManager, C.TypeOpenConnect, "OpenConnect client", func(endpoints []adapter.OpenConnectEndpoint) error {
|
||||
return server.Send(&OpenConnectStatusUpdate{
|
||||
Endpoints: common.Map(endpoints, func(endpoint adapter.OpenConnectEndpoint) *OpenConnectEndpointStatus {
|
||||
return openConnectEndpointStatusToProto(endpoint.Tag(), endpoint.OpenConnectStatus())
|
||||
return openConnectEndpointStatusToProto(endpoint.Tag(), endpoint.OpenConnectStatus(), selectedLocale)
|
||||
}),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func openConnectEndpointStatusToProto(tag string, endpointStatus adapter.OpenConnectStatus) *OpenConnectEndpointStatus {
|
||||
func openConnectEndpointStatusToProto(tag string, endpointStatus adapter.OpenConnectStatus, selectedLocale *locale.Locale) *OpenConnectEndpointStatus {
|
||||
result := &OpenConnectEndpointStatus{
|
||||
EndpointTag: tag,
|
||||
State: endpointStatus.State,
|
||||
StateText: selectedLocale.VPNStateText(endpointStatus.State),
|
||||
Error: endpointStatus.Error,
|
||||
TunnelInfo: openConnectTunnelInfoToProto(endpointStatus.TunnelInfo),
|
||||
}
|
||||
@@ -1696,19 +1701,21 @@ func (s *StartedService) SubscribeOpenVPNStatus(
|
||||
s.serviceAccess.RUnlock()
|
||||
|
||||
endpointManager := service.FromContext[adapter.EndpointManager](boxService.ctx)
|
||||
selectedLocale := locale.FromContext(server.Context())
|
||||
return subscribeEndpointStatus(server.Context(), endpointManager, C.TypeOpenVPNClient, "OpenVPN client", func(endpoints []adapter.OpenVPNEndpoint) error {
|
||||
return server.Send(&OpenVPNStatusUpdate{
|
||||
Endpoints: common.Map(endpoints, func(endpoint adapter.OpenVPNEndpoint) *OpenVPNEndpointStatus {
|
||||
return openVPNEndpointStatusToProto(endpoint.Tag(), endpoint.OpenVPNStatus())
|
||||
return openVPNEndpointStatusToProto(endpoint.Tag(), endpoint.OpenVPNStatus(), selectedLocale)
|
||||
}),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func openVPNEndpointStatusToProto(tag string, endpointStatus adapter.OpenVPNStatus) *OpenVPNEndpointStatus {
|
||||
func openVPNEndpointStatusToProto(tag string, endpointStatus adapter.OpenVPNStatus, selectedLocale *locale.Locale) *OpenVPNEndpointStatus {
|
||||
result := &OpenVPNEndpointStatus{
|
||||
EndpointTag: tag,
|
||||
State: endpointStatus.State,
|
||||
StateText: selectedLocale.VPNStateText(endpointStatus.State),
|
||||
Error: endpointStatus.Error,
|
||||
TunnelInfo: openVPNTunnelInfoToProto(endpointStatus.TunnelInfo),
|
||||
}
|
||||
|
||||
@@ -2222,13 +2222,14 @@ type TailscaleEndpointStatus struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
EndpointTag string `protobuf:"bytes,1,opt,name=endpointTag,proto3" json:"endpointTag,omitempty"`
|
||||
BackendState string `protobuf:"bytes,2,opt,name=backendState,proto3" json:"backendState,omitempty"`
|
||||
AuthURL string `protobuf:"bytes,3,opt,name=authURL,proto3" json:"authURL,omitempty"`
|
||||
NetworkName string `protobuf:"bytes,4,opt,name=networkName,proto3" json:"networkName,omitempty"`
|
||||
MagicDNSSuffix string `protobuf:"bytes,5,opt,name=magicDNSSuffix,proto3" json:"magicDNSSuffix,omitempty"`
|
||||
Self *TailscalePeer `protobuf:"bytes,6,opt,name=self,proto3" json:"self,omitempty"`
|
||||
UserGroups []*TailscaleUserGroup `protobuf:"bytes,7,rep,name=userGroups,proto3" json:"userGroups,omitempty"`
|
||||
ExitNode *TailscalePeer `protobuf:"bytes,8,opt,name=exitNode,proto3" json:"exitNode,omitempty"`
|
||||
KeyAuth bool `protobuf:"varint,9,opt,name=keyAuth,proto3" json:"keyAuth,omitempty"`
|
||||
StateText string `protobuf:"bytes,3,opt,name=stateText,proto3" json:"stateText,omitempty"`
|
||||
AuthURL string `protobuf:"bytes,4,opt,name=authURL,proto3" json:"authURL,omitempty"`
|
||||
NetworkName string `protobuf:"bytes,5,opt,name=networkName,proto3" json:"networkName,omitempty"`
|
||||
MagicDNSSuffix string `protobuf:"bytes,6,opt,name=magicDNSSuffix,proto3" json:"magicDNSSuffix,omitempty"`
|
||||
Self *TailscalePeer `protobuf:"bytes,7,opt,name=self,proto3" json:"self,omitempty"`
|
||||
UserGroups []*TailscaleUserGroup `protobuf:"bytes,8,rep,name=userGroups,proto3" json:"userGroups,omitempty"`
|
||||
ExitNode *TailscalePeer `protobuf:"bytes,9,opt,name=exitNode,proto3" json:"exitNode,omitempty"`
|
||||
KeyAuth bool `protobuf:"varint,10,opt,name=keyAuth,proto3" json:"keyAuth,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -2277,6 +2278,13 @@ func (x *TailscaleEndpointStatus) GetBackendState() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *TailscaleEndpointStatus) GetStateText() string {
|
||||
if x != nil {
|
||||
return x.StateText
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *TailscaleEndpointStatus) GetAuthURL() string {
|
||||
if x != nil {
|
||||
return x.AuthURL
|
||||
@@ -4666,9 +4674,10 @@ type OpenConnectEndpointStatus struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
EndpointTag string `protobuf:"bytes,1,opt,name=endpointTag,proto3" json:"endpointTag,omitempty"`
|
||||
State string `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"`
|
||||
AuthForm *OpenConnectAuthForm `protobuf:"bytes,3,opt,name=authForm,proto3" json:"authForm,omitempty"`
|
||||
Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"`
|
||||
TunnelInfo *OpenConnectTunnelInfo `protobuf:"bytes,5,opt,name=tunnelInfo,proto3" json:"tunnelInfo,omitempty"`
|
||||
StateText string `protobuf:"bytes,3,opt,name=stateText,proto3" json:"stateText,omitempty"`
|
||||
AuthForm *OpenConnectAuthForm `protobuf:"bytes,4,opt,name=authForm,proto3" json:"authForm,omitempty"`
|
||||
Error string `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"`
|
||||
TunnelInfo *OpenConnectTunnelInfo `protobuf:"bytes,6,opt,name=tunnelInfo,proto3" json:"tunnelInfo,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -4717,6 +4726,13 @@ func (x *OpenConnectEndpointStatus) GetState() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *OpenConnectEndpointStatus) GetStateText() string {
|
||||
if x != nil {
|
||||
return x.StateText
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *OpenConnectEndpointStatus) GetAuthForm() *OpenConnectAuthForm {
|
||||
if x != nil {
|
||||
return x.AuthForm
|
||||
@@ -5218,9 +5234,10 @@ type OpenVPNEndpointStatus struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
EndpointTag string `protobuf:"bytes,1,opt,name=endpointTag,proto3" json:"endpointTag,omitempty"`
|
||||
State string `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"`
|
||||
Challenge *OpenVPNChallenge `protobuf:"bytes,3,opt,name=challenge,proto3" json:"challenge,omitempty"`
|
||||
Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"`
|
||||
TunnelInfo *OpenVPNTunnelInfo `protobuf:"bytes,5,opt,name=tunnelInfo,proto3" json:"tunnelInfo,omitempty"`
|
||||
StateText string `protobuf:"bytes,3,opt,name=stateText,proto3" json:"stateText,omitempty"`
|
||||
Challenge *OpenVPNChallenge `protobuf:"bytes,4,opt,name=challenge,proto3" json:"challenge,omitempty"`
|
||||
Error string `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"`
|
||||
TunnelInfo *OpenVPNTunnelInfo `protobuf:"bytes,6,opt,name=tunnelInfo,proto3" json:"tunnelInfo,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -5269,6 +5286,13 @@ func (x *OpenVPNEndpointStatus) GetState() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *OpenVPNEndpointStatus) GetStateText() string {
|
||||
if x != nil {
|
||||
return x.StateText
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *OpenVPNEndpointStatus) GetChallenge() *OpenVPNChallenge {
|
||||
if x != nil {
|
||||
return x.Challenge
|
||||
@@ -5844,19 +5868,21 @@ const file_daemon_started_service_proto_rawDesc = "" +
|
||||
"\x05error\x18\a \x01(\tR\x05error\x12*\n" +
|
||||
"\x10natTypeSupported\x18\b \x01(\bR\x10natTypeSupported\"V\n" +
|
||||
"\x15TailscaleStatusUpdate\x12=\n" +
|
||||
"\tendpoints\x18\x01 \x03(\v2\x1f.daemon.TailscaleEndpointStatusR\tendpoints\"\xf7\x02\n" +
|
||||
"\tendpoints\x18\x01 \x03(\v2\x1f.daemon.TailscaleEndpointStatusR\tendpoints\"\x95\x03\n" +
|
||||
"\x17TailscaleEndpointStatus\x12 \n" +
|
||||
"\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12\"\n" +
|
||||
"\fbackendState\x18\x02 \x01(\tR\fbackendState\x12\x18\n" +
|
||||
"\aauthURL\x18\x03 \x01(\tR\aauthURL\x12 \n" +
|
||||
"\vnetworkName\x18\x04 \x01(\tR\vnetworkName\x12&\n" +
|
||||
"\x0emagicDNSSuffix\x18\x05 \x01(\tR\x0emagicDNSSuffix\x12)\n" +
|
||||
"\x04self\x18\x06 \x01(\v2\x15.daemon.TailscalePeerR\x04self\x12:\n" +
|
||||
"\fbackendState\x18\x02 \x01(\tR\fbackendState\x12\x1c\n" +
|
||||
"\tstateText\x18\x03 \x01(\tR\tstateText\x12\x18\n" +
|
||||
"\aauthURL\x18\x04 \x01(\tR\aauthURL\x12 \n" +
|
||||
"\vnetworkName\x18\x05 \x01(\tR\vnetworkName\x12&\n" +
|
||||
"\x0emagicDNSSuffix\x18\x06 \x01(\tR\x0emagicDNSSuffix\x12)\n" +
|
||||
"\x04self\x18\a \x01(\v2\x15.daemon.TailscalePeerR\x04self\x12:\n" +
|
||||
"\n" +
|
||||
"userGroups\x18\a \x03(\v2\x1a.daemon.TailscaleUserGroupR\n" +
|
||||
"userGroups\x18\b \x03(\v2\x1a.daemon.TailscaleUserGroupR\n" +
|
||||
"userGroups\x121\n" +
|
||||
"\bexitNode\x18\b \x01(\v2\x15.daemon.TailscalePeerR\bexitNode\x12\x18\n" +
|
||||
"\akeyAuth\x18\t \x01(\bR\akeyAuth\"\xbf\x01\n" +
|
||||
"\bexitNode\x18\t \x01(\v2\x15.daemon.TailscalePeerR\bexitNode\x12\x18\n" +
|
||||
"\akeyAuth\x18\n" +
|
||||
" \x01(\bR\akeyAuth\"\xbf\x01\n" +
|
||||
"\x12TailscaleUserGroup\x12\x16\n" +
|
||||
"\x06userID\x18\x01 \x01(\x03R\x06userID\x12\x1c\n" +
|
||||
"\tloginName\x18\x02 \x01(\tR\tloginName\x12 \n" +
|
||||
@@ -6040,14 +6066,15 @@ const file_daemon_started_service_proto_rawDesc = "" +
|
||||
"\abackend\x18\x04 \x01(\x0e2\x12.daemon.USBBackendR\abackend\x12,\n" +
|
||||
"\x05state\x18\x05 \x01(\x0e2\x16.daemon.USBDeviceStateR\x05state\"Z\n" +
|
||||
"\x17OpenConnectStatusUpdate\x12?\n" +
|
||||
"\tendpoints\x18\x01 \x03(\v2!.daemon.OpenConnectEndpointStatusR\tendpoints\"\xe1\x01\n" +
|
||||
"\tendpoints\x18\x01 \x03(\v2!.daemon.OpenConnectEndpointStatusR\tendpoints\"\xff\x01\n" +
|
||||
"\x19OpenConnectEndpointStatus\x12 \n" +
|
||||
"\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12\x14\n" +
|
||||
"\x05state\x18\x02 \x01(\tR\x05state\x127\n" +
|
||||
"\bauthForm\x18\x03 \x01(\v2\x1b.daemon.OpenConnectAuthFormR\bauthForm\x12\x14\n" +
|
||||
"\x05error\x18\x04 \x01(\tR\x05error\x12=\n" +
|
||||
"\x05state\x18\x02 \x01(\tR\x05state\x12\x1c\n" +
|
||||
"\tstateText\x18\x03 \x01(\tR\tstateText\x127\n" +
|
||||
"\bauthForm\x18\x04 \x01(\v2\x1b.daemon.OpenConnectAuthFormR\bauthForm\x12\x14\n" +
|
||||
"\x05error\x18\x05 \x01(\tR\x05error\x12=\n" +
|
||||
"\n" +
|
||||
"tunnelInfo\x18\x05 \x01(\v2\x1d.daemon.OpenConnectTunnelInfoR\n" +
|
||||
"tunnelInfo\x18\x06 \x01(\v2\x1d.daemon.OpenConnectTunnelInfoR\n" +
|
||||
"tunnelInfo\"\xd9\x01\n" +
|
||||
"\x15OpenConnectTunnelInfo\x12\x16\n" +
|
||||
"\x06server\x18\x01 \x01(\tR\x06server\x12\x16\n" +
|
||||
@@ -6086,14 +6113,15 @@ const file_daemon_started_service_proto_rawDesc = "" +
|
||||
"\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12\x16\n" +
|
||||
"\x06formID\x18\x02 \x01(\tR\x06formID\"R\n" +
|
||||
"\x13OpenVPNStatusUpdate\x12;\n" +
|
||||
"\tendpoints\x18\x01 \x03(\v2\x1d.daemon.OpenVPNEndpointStatusR\tendpoints\"\xd8\x01\n" +
|
||||
"\tendpoints\x18\x01 \x03(\v2\x1d.daemon.OpenVPNEndpointStatusR\tendpoints\"\xf6\x01\n" +
|
||||
"\x15OpenVPNEndpointStatus\x12 \n" +
|
||||
"\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12\x14\n" +
|
||||
"\x05state\x18\x02 \x01(\tR\x05state\x126\n" +
|
||||
"\tchallenge\x18\x03 \x01(\v2\x18.daemon.OpenVPNChallengeR\tchallenge\x12\x14\n" +
|
||||
"\x05error\x18\x04 \x01(\tR\x05error\x129\n" +
|
||||
"\x05state\x18\x02 \x01(\tR\x05state\x12\x1c\n" +
|
||||
"\tstateText\x18\x03 \x01(\tR\tstateText\x126\n" +
|
||||
"\tchallenge\x18\x04 \x01(\v2\x18.daemon.OpenVPNChallengeR\tchallenge\x12\x14\n" +
|
||||
"\x05error\x18\x05 \x01(\tR\x05error\x129\n" +
|
||||
"\n" +
|
||||
"tunnelInfo\x18\x05 \x01(\v2\x19.daemon.OpenVPNTunnelInfoR\n" +
|
||||
"tunnelInfo\x18\x06 \x01(\v2\x19.daemon.OpenVPNTunnelInfoR\n" +
|
||||
"tunnelInfo\"\xd7\x01\n" +
|
||||
"\x11OpenVPNTunnelInfo\x12\x16\n" +
|
||||
"\x06server\x18\x01 \x01(\tR\x06server\x12\x18\n" +
|
||||
@@ -6284,6 +6312,7 @@ var (
|
||||
(*emptypb.Empty)(nil), // 82: google.protobuf.Empty
|
||||
}
|
||||
)
|
||||
|
||||
var file_daemon_started_service_proto_depIdxs = []int32{
|
||||
4, // 0: daemon.ServiceStatus.status:type_name -> daemon.ServiceStatus.Type
|
||||
80, // 1: daemon.Log.messages:type_name -> daemon.Log.Message
|
||||
|
||||
@@ -274,13 +274,14 @@ message TailscaleStatusUpdate {
|
||||
message TailscaleEndpointStatus {
|
||||
string endpointTag = 1;
|
||||
string backendState = 2;
|
||||
string authURL = 3;
|
||||
string networkName = 4;
|
||||
string magicDNSSuffix = 5;
|
||||
TailscalePeer self = 6;
|
||||
repeated TailscaleUserGroup userGroups = 7;
|
||||
TailscalePeer exitNode = 8;
|
||||
bool keyAuth = 9;
|
||||
string stateText = 3;
|
||||
string authURL = 4;
|
||||
string networkName = 5;
|
||||
string magicDNSSuffix = 6;
|
||||
TailscalePeer self = 7;
|
||||
repeated TailscaleUserGroup userGroups = 8;
|
||||
TailscalePeer exitNode = 9;
|
||||
bool keyAuth = 10;
|
||||
}
|
||||
|
||||
message TailscaleUserGroup {
|
||||
@@ -530,9 +531,10 @@ message OpenConnectStatusUpdate {
|
||||
message OpenConnectEndpointStatus {
|
||||
string endpointTag = 1;
|
||||
string state = 2;
|
||||
OpenConnectAuthForm authForm = 3;
|
||||
string error = 4;
|
||||
OpenConnectTunnelInfo tunnelInfo = 5;
|
||||
string stateText = 3;
|
||||
OpenConnectAuthForm authForm = 4;
|
||||
string error = 5;
|
||||
OpenConnectTunnelInfo tunnelInfo = 6;
|
||||
}
|
||||
|
||||
message OpenConnectTunnelInfo {
|
||||
@@ -587,9 +589,10 @@ message OpenVPNStatusUpdate {
|
||||
message OpenVPNEndpointStatus {
|
||||
string endpointTag = 1;
|
||||
string state = 2;
|
||||
OpenVPNChallenge challenge = 3;
|
||||
string error = 4;
|
||||
OpenVPNTunnelInfo tunnelInfo = 5;
|
||||
string stateText = 3;
|
||||
OpenVPNChallenge challenge = 4;
|
||||
string error = 5;
|
||||
OpenVPNTunnelInfo tunnelInfo = 6;
|
||||
}
|
||||
|
||||
message OpenVPNTunnelInfo {
|
||||
|
||||
@@ -63,8 +63,8 @@ func runWorker() error {
|
||||
}
|
||||
defer listener.Close()
|
||||
server := grpc.NewServer(
|
||||
grpc.ChainUnaryInterceptor(unaryLocaleInterceptor),
|
||||
grpc.ChainStreamInterceptor(streamLocaleInterceptor),
|
||||
grpc.ChainUnaryInterceptor(daemon.UnaryLocaleInterceptor),
|
||||
grpc.ChainStreamInterceptor(daemon.StreamLocaleInterceptor),
|
||||
)
|
||||
RegisterApplicationServiceServer(server, &applicationService{
|
||||
startedService: daemon.NewStartedService(daemon.ServiceOptions{Context: include.Context(context.Background())}),
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
|
||||
"github.com/sagernet/sing-box/experimental/locale"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
func setLocaleFromContext(ctx context.Context) {
|
||||
requestMetadata, loaded := metadata.FromIncomingContext(ctx)
|
||||
if !loaded {
|
||||
return
|
||||
}
|
||||
slices.ContainsFunc(requestMetadata.Get("accept-language"), locale.Set)
|
||||
}
|
||||
|
||||
func unaryLocaleInterceptor(ctx context.Context, request any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||
setLocaleFromContext(ctx)
|
||||
return handler(ctx, request)
|
||||
}
|
||||
|
||||
func streamLocaleInterceptor(server any, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
|
||||
setLocaleFromContext(stream.Context())
|
||||
return handler(server, stream)
|
||||
}
|
||||
@@ -66,8 +66,8 @@ func newDaemon() (*Daemon, error) {
|
||||
})
|
||||
authorizer := newAuthorizer(d)
|
||||
serverOptions := []grpc.ServerOption{
|
||||
grpc.ChainUnaryInterceptor(newUnaryAuthorizeInterceptor(authorizer), unaryLocaleInterceptor),
|
||||
grpc.ChainStreamInterceptor(newStreamAuthorizeInterceptor(authorizer), streamLocaleInterceptor),
|
||||
grpc.ChainUnaryInterceptor(newUnaryAuthorizeInterceptor(authorizer), daemon.UnaryLocaleInterceptor),
|
||||
grpc.ChainStreamInterceptor(newStreamAuthorizeInterceptor(authorizer), daemon.StreamLocaleInterceptor),
|
||||
}
|
||||
platformOptions, err := platformServerOptions(d)
|
||||
if err != nil {
|
||||
|
||||
@@ -151,8 +151,8 @@ func networkConnectionFromFileDescriptor(fileDescriptor int32) (net.Conn, error)
|
||||
func localDialOptions(contextDialer func(context.Context, string) (net.Conn, error)) []grpc.DialOption {
|
||||
options := []grpc.DialOption{
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithUnaryInterceptor(unaryClientAuthInterceptor),
|
||||
grpc.WithStreamInterceptor(streamClientAuthInterceptor),
|
||||
grpc.WithChainUnaryInterceptor(daemon.UnaryClientLocaleInterceptor, unaryClientAuthInterceptor),
|
||||
grpc.WithChainStreamInterceptor(daemon.StreamClientLocaleInterceptor, streamClientAuthInterceptor),
|
||||
}
|
||||
if contextDialer != nil {
|
||||
options = append(options, grpc.WithContextDialer(contextDialer))
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/daemon"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
@@ -62,19 +63,24 @@ func newRemoteConnection(options *RemoteConnectionOptions) (*remoteConnection, e
|
||||
if port == "" {
|
||||
port = defaultPort
|
||||
}
|
||||
authorization := ""
|
||||
if options.Secret != "" {
|
||||
authorization = "Bearer " + options.Secret
|
||||
}
|
||||
dialOptions := []grpc.DialOption{
|
||||
grpc.WithTransportCredentials(transportCredentials),
|
||||
}
|
||||
if options.Secret != "" {
|
||||
authorization := "Bearer " + options.Secret
|
||||
dialOptions = append(dialOptions,
|
||||
grpc.WithUnaryInterceptor(func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
|
||||
return invoker(metadata.AppendToOutgoingContext(ctx, "authorization", authorization), method, req, reply, cc, opts...)
|
||||
}),
|
||||
grpc.WithStreamInterceptor(func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
|
||||
return streamer(metadata.AppendToOutgoingContext(ctx, "authorization", authorization), desc, cc, method, opts...)
|
||||
}),
|
||||
)
|
||||
grpc.WithChainUnaryInterceptor(daemon.UnaryClientLocaleInterceptor, func(ctx context.Context, method string, request, reply any, connection *grpc.ClientConn, invoker grpc.UnaryInvoker, options ...grpc.CallOption) error {
|
||||
if authorization != "" {
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", authorization)
|
||||
}
|
||||
return invoker(ctx, method, request, reply, connection, options...)
|
||||
}),
|
||||
grpc.WithChainStreamInterceptor(daemon.StreamClientLocaleInterceptor, func(ctx context.Context, description *grpc.StreamDesc, connection *grpc.ClientConn, method string, streamer grpc.Streamer, options ...grpc.CallOption) (grpc.ClientStream, error) {
|
||||
if authorization != "" {
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", authorization)
|
||||
}
|
||||
return streamer(ctx, description, connection, method, options...)
|
||||
}),
|
||||
}
|
||||
return &remoteConnection{
|
||||
target: net.JoinHostPort(host, port),
|
||||
|
||||
@@ -161,8 +161,8 @@ func (s *CommandServer) Start() error {
|
||||
}
|
||||
s.listener = listener
|
||||
serverOptions := []grpc.ServerOption{
|
||||
grpc.UnaryInterceptor(unaryAuthInterceptor),
|
||||
grpc.StreamInterceptor(streamAuthInterceptor),
|
||||
grpc.ChainUnaryInterceptor(unaryAuthInterceptor, daemon.UnaryLocaleInterceptor),
|
||||
grpc.ChainStreamInterceptor(streamAuthInterceptor, daemon.StreamLocaleInterceptor),
|
||||
}
|
||||
s.grpcServer = grpc.NewServer(serverOptions...)
|
||||
daemon.RegisterStartedServiceServer(s.grpcServer, s.StartedService)
|
||||
|
||||
@@ -21,6 +21,7 @@ type OpenConnectEndpointStatusIterator interface {
|
||||
type OpenConnectEndpointStatus struct {
|
||||
EndpointTag string
|
||||
State string
|
||||
StateText string
|
||||
AuthForm *OpenConnectAuthForm
|
||||
Error string
|
||||
TunnelInfo *OpenConnectTunnelInfo
|
||||
@@ -121,6 +122,7 @@ func openConnectEndpointStatusFromGRPC(status *daemon.OpenConnectEndpointStatus)
|
||||
result := &OpenConnectEndpointStatus{
|
||||
EndpointTag: status.EndpointTag,
|
||||
State: status.State,
|
||||
StateText: status.StateText,
|
||||
Error: status.Error,
|
||||
}
|
||||
if status.AuthForm != nil {
|
||||
|
||||
@@ -21,6 +21,7 @@ type OpenVPNEndpointStatusIterator interface {
|
||||
type OpenVPNEndpointStatus struct {
|
||||
EndpointTag string
|
||||
State string
|
||||
StateText string
|
||||
Challenge *OpenVPNChallenge
|
||||
Error string
|
||||
TunnelInfo *OpenVPNTunnelInfo
|
||||
@@ -86,6 +87,7 @@ func openVPNEndpointStatusFromGRPC(status *daemon.OpenVPNEndpointStatus) *OpenVP
|
||||
result := &OpenVPNEndpointStatus{
|
||||
EndpointTag: status.EndpointTag,
|
||||
State: status.State,
|
||||
StateText: status.StateText,
|
||||
Error: status.Error,
|
||||
}
|
||||
if status.Challenge != nil {
|
||||
|
||||
@@ -18,6 +18,7 @@ type TailscaleEndpointStatusIterator interface {
|
||||
type TailscaleEndpointStatus struct {
|
||||
EndpointTag string
|
||||
BackendState string
|
||||
StateText string
|
||||
AuthURL string
|
||||
NetworkName string
|
||||
MagicDNSSuffix string
|
||||
@@ -105,6 +106,7 @@ func tailscaleEndpointStatusFromGRPC(status *daemon.TailscaleEndpointStatus) *Ta
|
||||
result := &TailscaleEndpointStatus{
|
||||
EndpointTag: status.EndpointTag,
|
||||
BackendState: status.BackendState,
|
||||
StateText: status.StateText,
|
||||
AuthURL: status.AuthURL,
|
||||
NetworkName: status.NetworkName,
|
||||
MagicDNSSuffix: status.MagicDNSSuffix,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package locale
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
@@ -31,6 +32,18 @@ type Locale struct {
|
||||
DeprecatedMessageNoLink string
|
||||
InsecureFeatureMessage string
|
||||
ExternalPathFeature string
|
||||
TailscaleInitializing string
|
||||
TailscaleInUse string
|
||||
TailscaleNeedsLogin string
|
||||
TailscaleNeedsApproval string
|
||||
TailscaleStopped string
|
||||
TailscaleStarting string
|
||||
TailscaleRunning string
|
||||
VPNConnecting string
|
||||
VPNAuthentication string
|
||||
VPNConnected string
|
||||
VPNError string
|
||||
Unknown string
|
||||
}
|
||||
|
||||
var defaultLocale = &Locale{
|
||||
@@ -39,8 +52,22 @@ var defaultLocale = &Locale{
|
||||
DeprecatedMessageNoLink: "%s is deprecated in sing-box %s and will be removed in sing-box %s.",
|
||||
InsecureFeatureMessage: "%s is considered insecure in the graphical client for sing-box on Windows. Enable Insecure Mode in `Settings - Core - Insecure Mode` to use it.",
|
||||
ExternalPathFeature: "Access to %s (outside of the working directory) is considered insecure in the graphical client for sing-box on Windows. Enable Insecure Mode in `Settings - Core - Insecure Mode` to use it.",
|
||||
TailscaleInitializing: "Initializing",
|
||||
TailscaleInUse: "In use by another user",
|
||||
TailscaleNeedsLogin: "Needs login",
|
||||
TailscaleNeedsApproval: "Needs approval",
|
||||
TailscaleStopped: "Stopped",
|
||||
TailscaleStarting: "Starting",
|
||||
TailscaleRunning: "Running",
|
||||
VPNConnecting: "Connecting",
|
||||
VPNAuthentication: "Authentication required",
|
||||
VPNConnected: "Connected",
|
||||
VPNError: "Error",
|
||||
Unknown: "Unknown",
|
||||
}
|
||||
|
||||
type contextKey struct{}
|
||||
|
||||
func init() {
|
||||
current.Store(defaultLocale)
|
||||
}
|
||||
@@ -49,7 +76,41 @@ func Current() *Locale {
|
||||
return current.Load()
|
||||
}
|
||||
|
||||
func ContextWithLocale(ctx context.Context, localeID string) (context.Context, bool) {
|
||||
selectedLocale, loaded := selectLocale(localeID)
|
||||
if !loaded {
|
||||
return ctx, false
|
||||
}
|
||||
return context.WithValue(ctx, contextKey{}, selectedLocale), true
|
||||
}
|
||||
|
||||
func FromContext(ctx context.Context) *Locale {
|
||||
selectedLocale, loaded := ctx.Value(contextKey{}).(*Locale)
|
||||
if loaded {
|
||||
return selectedLocale
|
||||
}
|
||||
return Current()
|
||||
}
|
||||
|
||||
func selectLocale(localeID string) (*Locale, bool) {
|
||||
localeName, loaded := match(localeID)
|
||||
if !loaded {
|
||||
return nil, false
|
||||
}
|
||||
selectedLocale, loaded := localeRegistry[localeName]
|
||||
return selectedLocale, loaded
|
||||
}
|
||||
|
||||
func Set(localeID string) bool {
|
||||
selectedLocale, loaded := selectLocale(localeID)
|
||||
if !loaded {
|
||||
return false
|
||||
}
|
||||
current.Store(selectedLocale)
|
||||
return true
|
||||
}
|
||||
|
||||
func match(localeID string) (string, bool) {
|
||||
localeEntries := strings.Split(localeID, ",")
|
||||
for i, localeEntry := range localeEntries {
|
||||
languageID, options, hasOptions := strings.Cut(localeEntry, ";")
|
||||
@@ -74,7 +135,7 @@ func Set(localeID string) bool {
|
||||
localeID = strings.Join(localeEntries, ",")
|
||||
tags, _, err := language.ParseAcceptLanguage(localeID)
|
||||
if err != nil || len(tags) == 0 {
|
||||
return false
|
||||
return "", false
|
||||
}
|
||||
for i, tag := range tags {
|
||||
base, script, region := tag.Raw()
|
||||
@@ -94,10 +155,41 @@ func Set(localeID string) bool {
|
||||
tags[i] = language.MustParse(languageID)
|
||||
}
|
||||
_, localeIndex, _ := localeMatcher.Match(tags...)
|
||||
selectedLocale, loaded := localeRegistry[localeNames[localeIndex]]
|
||||
if !loaded {
|
||||
return false
|
||||
}
|
||||
current.Store(selectedLocale)
|
||||
return true
|
||||
return localeNames[localeIndex], true
|
||||
}
|
||||
|
||||
func (l *Locale) TailscaleStateText(state string) string {
|
||||
switch state {
|
||||
case "NoState":
|
||||
return l.TailscaleInitializing
|
||||
case "InUseOtherUser":
|
||||
return l.TailscaleInUse
|
||||
case "NeedsLogin":
|
||||
return l.TailscaleNeedsLogin
|
||||
case "NeedsMachineAuth":
|
||||
return l.TailscaleNeedsApproval
|
||||
case "Stopped":
|
||||
return l.TailscaleStopped
|
||||
case "Starting":
|
||||
return l.TailscaleStarting
|
||||
case "Running":
|
||||
return l.TailscaleRunning
|
||||
default:
|
||||
return l.Unknown
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Locale) VPNStateText(state string) string {
|
||||
switch state {
|
||||
case "connecting":
|
||||
return l.VPNConnecting
|
||||
case "auth-pending":
|
||||
return l.VPNAuthentication
|
||||
case "connected":
|
||||
return l.VPNConnected
|
||||
case "error":
|
||||
return l.VPNError
|
||||
default:
|
||||
return l.Unknown
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,17 @@ func init() {
|
||||
DeprecatedMessageNoLink: "%s از sing-box %s منسوخ شده است و در sing-box %s حذف خواهد شد.",
|
||||
InsecureFeatureMessage: "%s در کلاینت گرافیکی sing-box برای Windows ناامن تلقی می\u200cشود. برای استفاده، `حالت ناامن` را در `تنظیمات - هسته - حالت ناامن` فعال کنید.",
|
||||
ExternalPathFeature: "دسترسی به %s (خارج از پوشهٔ کاری) در کلاینت گرافیکی sing-box برای Windows ناامن تلقی می\u200cشود. برای استفاده، `حالت ناامن` را در `تنظیمات - هسته - حالت ناامن` فعال کنید.",
|
||||
TailscaleInitializing: "در حال راه\u200cاندازی",
|
||||
TailscaleInUse: "در حال استفاده توسط کاربر دیگری",
|
||||
TailscaleNeedsLogin: "نیاز به ورود",
|
||||
TailscaleNeedsApproval: "نیاز به تأیید",
|
||||
TailscaleStopped: "متوقف\u200cشده",
|
||||
TailscaleStarting: "در حال شروع",
|
||||
TailscaleRunning: "در حال اجرا",
|
||||
VPNConnecting: "در حال اتصال",
|
||||
VPNAuthentication: "نیاز به احراز هویت",
|
||||
VPNConnected: "متصل",
|
||||
VPNError: "خطا",
|
||||
Unknown: "ناشناخته",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,17 @@ func init() {
|
||||
DeprecatedMessageNoLink: "Использование %s устарело в sing-box %s, и эта возможность будет удалена в sing-box %s.",
|
||||
InsecureFeatureMessage: "%s считается небезопасным в графическом клиенте sing-box для Windows. Чтобы использовать эту возможность, включите `Небезопасный режим` в разделе `Настройки — Ядро — Небезопасный режим`.",
|
||||
ExternalPathFeature: "Доступ к %s (за пределами рабочего каталога) считается небезопасным в графическом клиенте sing-box для Windows. Чтобы использовать эту возможность, включите `Небезопасный режим` в разделе `Настройки — Ядро — Небезопасный режим`.",
|
||||
TailscaleInitializing: "Инициализация",
|
||||
TailscaleInUse: "Используется другим пользователем",
|
||||
TailscaleNeedsLogin: "Требуется вход",
|
||||
TailscaleNeedsApproval: "Требуется подтверждение",
|
||||
TailscaleStopped: "Остановлено",
|
||||
TailscaleStarting: "Запуск",
|
||||
TailscaleRunning: "Работает",
|
||||
VPNConnecting: "Подключение",
|
||||
VPNAuthentication: "Требуется аутентификация",
|
||||
VPNConnected: "Подключено",
|
||||
VPNError: "Ошибка",
|
||||
Unknown: "Неизвестно",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,5 +9,17 @@ func init() {
|
||||
DeprecatedMessageNoLink: "%s 已在 sing-box %s 中被弃用,且将在 sing-box %s 中被移除。" + warningMessageForEndUsers,
|
||||
InsecureFeatureMessage: "%s 在 sing-box 的 Windows 图形客户端中被视为不安全。请在 `设置 - 核心 - 不安全模式` 中启用不安全模式后使用。",
|
||||
ExternalPathFeature: "访问 %s(位于工作目录之外)在 sing-box 的 Windows 图形客户端中是不安全的。请在 `设置 - 核心 - 不安全模式` 中启用不安全模式后使用。",
|
||||
TailscaleInitializing: "正在初始化",
|
||||
TailscaleInUse: "正由其他用户使用",
|
||||
TailscaleNeedsLogin: "需要登录",
|
||||
TailscaleNeedsApproval: "需要批准",
|
||||
TailscaleStopped: "已停止",
|
||||
TailscaleStarting: "启动中",
|
||||
TailscaleRunning: "运行中",
|
||||
VPNConnecting: "正在连接",
|
||||
VPNAuthentication: "需要认证",
|
||||
VPNConnected: "已连接",
|
||||
VPNError: "错误",
|
||||
Unknown: "未知",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,17 @@ func init() {
|
||||
DeprecatedMessageNoLink: "%s 已在 sing-box %s 中棄用,且將在 sing-box %s 中移除。",
|
||||
InsecureFeatureMessage: "%s 在 sing-box 的 Windows 圖形用戶端中被視為不安全。請在 `設置 - 核心 - 不安全模式` 中啟用不安全模式後使用。",
|
||||
ExternalPathFeature: "存取 %s(位於工作目錄之外)在 sing-box 的 Windows 圖形用戶端中被視為不安全。請在 `設置 - 核心 - 不安全模式` 中啟用不安全模式後使用。",
|
||||
TailscaleInitializing: "正在初始化",
|
||||
TailscaleInUse: "正由其他使用者使用",
|
||||
TailscaleNeedsLogin: "需要登入",
|
||||
TailscaleNeedsApproval: "需要核准",
|
||||
TailscaleStopped: "已停止",
|
||||
TailscaleStarting: "啟動中",
|
||||
TailscaleRunning: "執行中",
|
||||
VPNConnecting: "正在連線",
|
||||
VPNAuthentication: "需要認證",
|
||||
VPNConnected: "已連線",
|
||||
VPNError: "錯誤",
|
||||
Unknown: "未知",
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user