Skip to content

Commit 6a2f506

Browse files
committed
支持远程
1 parent de601a3 commit 6a2f506

20 files changed

Lines changed: 1606 additions & 64 deletions

Assets/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@
44
* 优化了侧边消息快速跳转组件的样式与动画
55
* 统一了项目编辑窗口和设置窗口的外观样式
66
* 修复了快速切换长对话时可能出现滚动异常的问题
7+
* 新增“共享至局域网”配置项,启用后即可在局域网内其他设备访问指定的 IP 地址操作软件, 多端操作实时同步, 移动端访问会自动切换布局
8+
* 调整消息操作菜单为横向展开

Assets/Langs/zh-CN.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@
126126
"Navigation.RenameSession": "重命名对话",
127127
"Navigation.Session": "对话",
128128
"Navigation.Settings": "设置",
129+
"Navigation.Sessions": "会话",
130+
"Navigation.Conversation": "对话",
131+
"Navigation.Details": "信息",
129132
"Phase.DirectiveContentPlaceholder": "输入指令内容",
130133
"Phase.EnterDirectives": "进入指令",
131134
"Phase.ExitDirectives": "退出指令",
@@ -185,6 +188,7 @@
185188
"Settings.Language.Language": "界面语言",
186189
"Settings.Language.Title": "语言",
187190
"Settings.Others.Title": "其他",
191+
"Settings.Others.LanSharing": "开启局域网共享",
188192
"Settings.Model.DisplayName": "名称",
189193
"Settings.Model.ExtraParameters": "自定义参数 (JSON 格式)",
190194
"Settings.Model.Prompt": "提示词",
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace DirectorPrompt.Domain.Configurations;
2+
3+
public sealed class RemoteControlConfig
4+
{
5+
public bool IsLanSharingEnabled { get; set; }
6+
7+
public int Port { get; set; } = 32145;
8+
}

DirectorPrompt.Domain/Configurations/UserSettings.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,6 @@ public class UserSettings
99
public LocalizationConfig Localization { get; set; } = new();
1010

1111
public SessionStateConfig Session { get; set; } = new();
12+
13+
public RemoteControlConfig RemoteControl { get; set; } = new();
1214
}

DirectorPrompt.Tests/AvaloniaInteractionTests.cs

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1+
using System.ComponentModel;
12
using Avalonia.Controls;
23
using Avalonia.Controls.Primitives;
34
using Avalonia.Data;
45
using Avalonia.Headless.XUnit;
56
using Avalonia.Interactivity;
67
using Avalonia.LogicalTree;
8+
using Avalonia.Threading;
79
using Avalonia.VisualTree;
810
using Avalonia.Controls.Shapes;
911
using DirectorPrompt.Domain.Enums;
12+
using DirectorPrompt.Services;
1013
using DirectorPrompt.ViewModels;
1114
using DirectorPrompt.Views;
1215
using DirectorPrompt.Views.Components;
@@ -52,6 +55,105 @@ public void SettingsNavigationSwitchesVisiblePanel()
5255
window.Close();
5356
}
5457

58+
[AvaloniaFact]
59+
public void SettingsOthersPanelContainsLanSharingToggle()
60+
{
61+
var window = new SettingsWindow();
62+
63+
Assert.NotNull(window.FindControl<ToggleSwitch>("LanSharingToggle"));
64+
}
65+
66+
[AvaloniaFact]
67+
public void RemoteMainWindowUsesMobileLayout()
68+
{
69+
var viewModel = new MainViewModel
70+
(
71+
null!,
72+
null!,
73+
null!,
74+
null!,
75+
null!,
76+
null!,
77+
null!,
78+
null!,
79+
null!,
80+
null!,
81+
null!,
82+
null!,
83+
null!,
84+
new LanSharingStub()
85+
);
86+
var entry = new DialogEntryViewModel { Content = "Message" };
87+
viewModel.Dialog.Entries.Add(entry);
88+
var remoteWindow = new MainWindow(viewModel, false);
89+
var content = Assert.IsAssignableFrom<Control>(remoteWindow.Content);
90+
remoteWindow.Content = null;
91+
content.DataContext = viewModel;
92+
93+
var host = new Window { Width = 390, Height = 700, Content = content };
94+
host.Show();
95+
Dispatcher.UIThread.RunJobs();
96+
97+
string[] controlNames =
98+
[
99+
"MobileNavigation",
100+
"EditProjectButton",
101+
"DetailsPanel",
102+
"MobileDetailsButton",
103+
"MobileSessionsButton",
104+
"SessionSidebar",
105+
"MobileConversationButton",
106+
"MobileMessageRail",
107+
"DialogListBox",
108+
"MobileProjectToolbar"
109+
];
110+
var controls = content.GetLogicalDescendants()
111+
.OfType<Control>()
112+
.Where(control => control.Name is not null && controlNames.Contains(control.Name))
113+
.ToDictionary(static control => control.Name!);
114+
115+
Assert.True(controls["MobileNavigation"].IsVisible);
116+
Assert.False(controls["EditProjectButton"].IsVisible);
117+
Assert.False(controls["DetailsPanel"].IsVisible);
118+
Assert.True(controls["MobileProjectToolbar"].IsVisible);
119+
Assert.Equal(1, Grid.GetColumn(controls["MobileMessageRail"]));
120+
121+
((ListBox)controls["DialogListBox"]).ScrollIntoView(entry);
122+
Dispatcher.UIThread.RunJobs();
123+
var messageMenuButton = content.GetVisualDescendants()
124+
.OfType<Button>()
125+
.First(button => button.Name == "MessageMenuButton");
126+
Assert.True(messageMenuButton.IsEnabled);
127+
messageMenuButton.RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
128+
Assert.True(entry.IsMenuOpen);
129+
var messageActionsPanel = content.GetVisualDescendants()
130+
.OfType<Border>()
131+
.First(border => border.Name == "MessageActionsPanel");
132+
Assert.True(messageActionsPanel.IsHitTestVisible);
133+
Assert.Contains("open", messageActionsPanel.Classes);
134+
Assert.Same(messageActionsPanel.Parent, messageMenuButton.Parent);
135+
136+
var sessionsWidth = controls["MobileSessionsButton"].Bounds.Width;
137+
var conversationWidth = controls["MobileConversationButton"].Bounds.Width;
138+
var detailsWidth = controls["MobileDetailsButton"].Bounds.Width;
139+
Assert.InRange(Math.Abs(sessionsWidth - conversationWidth), 0, 1);
140+
Assert.InRange(Math.Abs(conversationWidth - detailsWidth), 0, 1);
141+
142+
controls["MobileDetailsButton"].RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
143+
Assert.True(controls["DetailsPanel"].IsVisible);
144+
145+
controls["MobileSessionsButton"].RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
146+
Assert.True(controls["SessionSidebar"].IsVisible);
147+
148+
controls["MobileConversationButton"].RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
149+
Assert.False(controls["SessionSidebar"].IsVisible);
150+
Assert.False(controls["DetailsPanel"].IsVisible);
151+
Assert.True(controls["MobileMessageRail"].IsVisible);
152+
Assert.True(((ToggleButton)controls["MobileConversationButton"]).IsChecked);
153+
154+
host.Close();
155+
}
156+
55157
[AvaloniaFact]
56158
public void ProjectNavigationSwitchesVisiblePanel()
57159
{
@@ -149,4 +251,20 @@ public void CharacterAndMemoryFiltersRestoreDefaultSelections()
149251

150252
window.Close();
151253
}
254+
255+
private sealed class LanSharingStub : ILanSharingService
256+
{
257+
public Uri? Endpoint => null;
258+
259+
public bool IsActive => false;
260+
261+
public event PropertyChangedEventHandler? PropertyChanged
262+
{
263+
add { }
264+
remove { }
265+
}
266+
267+
public Task ApplyAsync(bool enabled, CancellationToken cancellationToken = default) =>
268+
Task.CompletedTask;
269+
}
152270
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
using System.Net;
2+
using System.Net.Sockets;
3+
using System.Net.WebSockets;
4+
using System.Text;
5+
using Avalonia.Remote.Protocol.Viewport;
6+
using DirectorPrompt.Domain.Configurations;
7+
using DirectorPrompt.Services;
8+
9+
namespace DirectorPrompt.Tests;
10+
11+
public sealed class BrowserRemoteTransportTests
12+
{
13+
[Fact]
14+
public void RemoteControlConfigUsesStableDefaultPort()
15+
{
16+
var config = new RemoteControlConfig();
17+
18+
Assert.Equal(32145, config.Port);
19+
}
20+
21+
[Fact]
22+
public async Task ServerServesRemotePageAndReleasesPort()
23+
{
24+
var port = GetAvailablePort();
25+
var transport = new BrowserRemoteTransport(IPAddress.Loopback, port);
26+
27+
await transport.StartServerAsync();
28+
29+
using var client = new HttpClient();
30+
var page = await client.GetStringAsync($"http://127.0.0.1:{port}/");
31+
32+
Assert.Contains("<canvas id=\"screen\"", page);
33+
Assert.Contains("id=\"keyboardButton\"", page);
34+
Assert.Contains("Math.max(360,innerWidth)", page);
35+
36+
await transport.DisposeAsync();
37+
38+
var listener = new TcpListener(IPAddress.Loopback, port);
39+
listener.Start();
40+
listener.Stop();
41+
}
42+
43+
[Fact]
44+
public async Task FrameProducedBeforeConnectionIsDelivered()
45+
{
46+
var port = GetAvailablePort();
47+
var transport = new BrowserRemoteTransport(IPAddress.Loopback, port);
48+
await transport.StartServerAsync();
49+
await transport.Send(new FrameMessage
50+
{
51+
SequenceId = 7,
52+
Width = 1,
53+
Height = 1,
54+
Stride = 4,
55+
DpiX = 96,
56+
DpiY = 96,
57+
Format = PixelFormat.Rgba8888,
58+
Data = [1, 2, 3, 4]
59+
});
60+
61+
using var socket = new ClientWebSocket();
62+
await socket.ConnectAsync(new Uri($"ws://127.0.0.1:{port}/remote"), CancellationToken.None);
63+
64+
var buffer = new byte[256];
65+
var headerResult = await socket.ReceiveAsync(buffer, CancellationToken.None);
66+
var header = Encoding.UTF8.GetString(buffer, 0, headerResult.Count);
67+
var frameResult = await socket.ReceiveAsync(buffer, CancellationToken.None);
68+
69+
Assert.StartsWith("frame:7:1:1:4", header);
70+
Assert.Equal(WebSocketMessageType.Binary, frameResult.MessageType);
71+
Assert.Equal([1, 2, 3, 4], buffer[..frameResult.Count]);
72+
73+
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None);
74+
await transport.DisposeAsync();
75+
}
76+
77+
[Fact]
78+
public async Task DisposeCompletesWithConnectedBrowser()
79+
{
80+
var port = GetAvailablePort();
81+
var transport = new BrowserRemoteTransport(IPAddress.Loopback, port);
82+
await transport.StartServerAsync();
83+
84+
using var socket = new ClientWebSocket();
85+
await socket.ConnectAsync(new Uri($"ws://127.0.0.1:{port}/remote"), CancellationToken.None);
86+
87+
await transport.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5));
88+
}
89+
90+
private static int GetAvailablePort()
91+
{
92+
var listener = new TcpListener(IPAddress.Loopback, 0);
93+
listener.Start();
94+
95+
try
96+
{
97+
return ((IPEndPoint)listener.LocalEndpoint).Port;
98+
}
99+
finally
100+
{
101+
listener.Stop();
102+
}
103+
}
104+
}

DirectorPrompt/App.axaml.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,17 @@ public override async void OnFrameworkInitializationCompleted()
8787
desktop.MainWindow = mainWindow;
8888
desktop.ShutdownMode = ShutdownMode.OnLastWindowClose;
8989
mainWindow.Show();
90+
91+
var lanSharingService = host.Services.GetRequiredService<ILanSharingService>();
92+
93+
try
94+
{
95+
await lanSharingService.ApplyAsync(host.Services.GetRequiredService<UserSettings>().RemoteControl.IsLanSharingEnabled);
96+
}
97+
catch (Exception ex)
98+
{
99+
Log.Error(ex, "启动局域网共享失败");
100+
}
90101
}
91102
catch (Exception ex)
92103
{
@@ -239,6 +250,7 @@ private static void ConfigureServices(IServiceCollection services, UserSettingsS
239250
services.AddSingleton<NotificationService>();
240251
services.AddSingleton<IWindowService, WindowService>();
241252
services.AddSingleton<IFilePickerService, FilePickerService>();
253+
services.AddSingleton<ILanSharingService, LanSharingService>();
242254

243255
services.AddSingleton<MainViewModel>();
244256
services.AddSingleton<MainWindow>();

0 commit comments

Comments
 (0)