Skip to content

Commit ab02b73

Browse files
committed
- Append headless mode
Add a no-interface mode so that the AI ​​Agent can run fully automatically.
1 parent 42a6a5b commit ab02b73

File tree

10 files changed

+290
-11
lines changed

10 files changed

+290
-11
lines changed

.dockerignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.git
2+
.vs
3+
.sonarqube
4+
**/bin
5+
**/obj
6+
publish
7+
images
8+
*.zip

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
*.db
1313
AGENTS.md
1414
Resources/
15+
[Pp]ublish/
1516

1617
# User-specific files (MonoDevelop/Xamarin Studio)
1718
*.userprefs

AiyoPerps/HeadlessRuntime.cs

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
using AiyoPerps.Services;
2+
using AiyoPerps.Services.Api;
3+
using System;
4+
using System.Diagnostics;
5+
using System.Globalization;
6+
using System.Linq;
7+
using System.Threading;
8+
using System.Threading.Tasks;
9+
10+
namespace AiyoPerps;
11+
12+
internal static class HeadlessRuntime
13+
{
14+
private const int DefaultPort = 5078;
15+
16+
public static bool IsHeadless(string[] args)
17+
=> args.Any(static arg =>
18+
string.Equals(arg, "headless", StringComparison.OrdinalIgnoreCase) ||
19+
string.Equals(arg, "--headless", StringComparison.OrdinalIgnoreCase) ||
20+
string.Equals(arg, "/headless", StringComparison.OrdinalIgnoreCase));
21+
22+
public static async Task RunAsync(string[] args)
23+
{
24+
var logger = new AppLogger();
25+
logger.Info("Headless", "Headless mode bootstrap started");
26+
27+
var stopSignal = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
28+
using var shutdownCts = new CancellationTokenSource();
29+
30+
Console.CancelKeyPress += (_, e) =>
31+
{
32+
e.Cancel = true;
33+
stopSignal.TrySetResult("Console cancel requested");
34+
};
35+
36+
var userPreferenceRepository = new UserPreferenceRepository();
37+
var port = ResolvePort(args, userPreferenceRepository, logger);
38+
39+
var secretProtector = new AesSecretProtector();
40+
var accountRepository = new AccountRepository(secretProtector);
41+
var accountStore = new AccountStore(accountRepository);
42+
var venueFactory = new VenueFactory(logger);
43+
var symbolCatalogRepository = new SymbolCatalogRepository();
44+
var retentionScheduler = new RetentionScheduler(new RetentionJob(), retentionDays: 365);
45+
var symbolCatalogSyncService = new SymbolCatalogSyncService(symbolCatalogRepository, logger);
46+
var tradingApiService = new TradingApiService(accountStore, venueFactory, symbolCatalogRepository, logger);
47+
var localApiServer = new LocalApiServer(
48+
tradingApiService,
49+
logger,
50+
reason =>
51+
{
52+
stopSignal.TrySetResult(reason);
53+
return Task.CompletedTask;
54+
});
55+
56+
retentionScheduler.Start();
57+
58+
_ = Task.Run(async () =>
59+
{
60+
try
61+
{
62+
logger.Info("Headless", "Symbol catalog sync started (background)");
63+
await symbolCatalogSyncService.SyncAllAsync();
64+
logger.Info("Headless", "Symbol catalog sync completed");
65+
}
66+
catch (Exception ex)
67+
{
68+
logger.Error("Headless", "Symbol catalog sync failed", ex);
69+
}
70+
});
71+
72+
try
73+
{
74+
await localApiServer.StartAsync(
75+
port,
76+
new LocalApiServerStartOptions
77+
{
78+
BindLocalOnly = false,
79+
AllowRemoteOrigins = true
80+
},
81+
shutdownCts.Token);
82+
83+
logger.Info("Headless", $"Headless mode running. HTTP API auto-started on port={port}");
84+
await stopSignal.Task;
85+
}
86+
finally
87+
{
88+
shutdownCts.Cancel();
89+
await DisposeWithTimeoutAsync(localApiServer, tradingApiService, retentionScheduler, logger);
90+
}
91+
}
92+
93+
private static int ResolvePort(string[] args, UserPreferenceRepository preferences, AppLogger logger)
94+
{
95+
var argPort = TryReadPort(args);
96+
if (argPort.HasValue)
97+
{
98+
logger.Info("Headless", $"Using CLI HTTP API port={argPort.Value}");
99+
return argPort.Value;
100+
}
101+
102+
var envPort = TryParsePort(Environment.GetEnvironmentVariable("AIYOPERPS_HTTP_PORT"));
103+
if (envPort.HasValue)
104+
{
105+
logger.Info("Headless", $"Using environment HTTP API port={envPort.Value}");
106+
return envPort.Value;
107+
}
108+
109+
var preferencePort = preferences.GetHttpApiPortOrDefault(DefaultPort);
110+
logger.Info("Headless", $"Using saved/default HTTP API port={preferencePort}");
111+
return preferencePort;
112+
}
113+
114+
private static int? TryReadPort(string[] args)
115+
{
116+
for (var i = 0; i < args.Length; i++)
117+
{
118+
var arg = args[i];
119+
if (arg.StartsWith("--port=", StringComparison.OrdinalIgnoreCase) ||
120+
arg.StartsWith("port=", StringComparison.OrdinalIgnoreCase))
121+
{
122+
return TryParsePort(arg[(arg.IndexOf('=') + 1)..]);
123+
}
124+
125+
if (arg.StartsWith("--http-port=", StringComparison.OrdinalIgnoreCase) ||
126+
arg.StartsWith("http-port=", StringComparison.OrdinalIgnoreCase))
127+
{
128+
return TryParsePort(arg[(arg.IndexOf('=') + 1)..]);
129+
}
130+
131+
if ((string.Equals(arg, "--port", StringComparison.OrdinalIgnoreCase) ||
132+
string.Equals(arg, "--http-port", StringComparison.OrdinalIgnoreCase)) &&
133+
i + 1 < args.Length)
134+
{
135+
return TryParsePort(args[i + 1]);
136+
}
137+
}
138+
139+
return null;
140+
}
141+
142+
private static int? TryParsePort(string? value)
143+
{
144+
return int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var port) &&
145+
port is > 0 and <= 65535
146+
? port
147+
: null;
148+
}
149+
150+
private static async Task DisposeWithTimeoutAsync(
151+
LocalApiServer localApiServer,
152+
TradingApiService tradingApiService,
153+
RetentionScheduler retentionScheduler,
154+
AppLogger logger)
155+
{
156+
try
157+
{
158+
var apiDisposeTask = localApiServer.DisposeAsync().AsTask();
159+
var tradingDisposeTask = tradingApiService.DisposeAsync().AsTask();
160+
var allDisposeTask = Task.WhenAll(apiDisposeTask, tradingDisposeTask);
161+
var completed = await Task.WhenAny(allDisposeTask, Task.Delay(TimeSpan.FromSeconds(5))) == allDisposeTask;
162+
retentionScheduler.Dispose();
163+
164+
if (completed)
165+
{
166+
logger.Info("Headless", "Headless mode shutdown completed");
167+
return;
168+
}
169+
170+
logger.Warn("Headless", "Headless shutdown timeout after 5s. Forcing process termination.");
171+
ForceTerminateProcess();
172+
}
173+
catch (Exception ex)
174+
{
175+
retentionScheduler.Dispose();
176+
logger.Error("Headless", "Headless shutdown failed", ex);
177+
ForceTerminateProcess();
178+
}
179+
}
180+
181+
private static void ForceTerminateProcess()
182+
{
183+
try
184+
{
185+
Process.GetCurrentProcess().Kill(true);
186+
}
187+
catch
188+
{
189+
Environment.Exit(-1);
190+
}
191+
}
192+
}

AiyoPerps/Program.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,17 @@ internal class Program
1010
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
1111
// yet and stuff might break.
1212
[STAThread]
13-
public static void Main(string[] args) => BuildAvaloniaApp()
14-
.StartWithClassicDesktopLifetime(args, ShutdownMode.OnLastWindowClose);
13+
public static void Main(string[] args)
14+
{
15+
if (HeadlessRuntime.IsHeadless(args))
16+
{
17+
HeadlessRuntime.RunAsync(args).GetAwaiter().GetResult();
18+
return;
19+
}
20+
21+
BuildAvaloniaApp()
22+
.StartWithClassicDesktopLifetime(args, ShutdownMode.OnLastWindowClose);
23+
}
1524

1625
// Avalonia configuration, don't remove; also used by visual designer.
1726
public static AppBuilder BuildAvaloniaApp()

AiyoPerps/Services/Api/LocalApiServer.cs

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ public sealed class LocalApiServer : IAsyncDisposable
2525
private readonly SemaphoreSlim _gate = new(1, 1);
2626

2727
private WebApplication? _app;
28+
private LocalApiServerStartOptions _startOptions = new();
2829

2930
public LocalApiServer(TradingApiService trading, AppLogger logger, Func<string, Task>? requestShutdown = null)
3031
{
@@ -36,7 +37,10 @@ public LocalApiServer(TradingApiService trading, AppLogger logger, Func<string,
3637
public bool IsRunning => _app is not null;
3738
public int Port { get; private set; }
3839

39-
public async Task StartAsync(int port, CancellationToken cancellationToken = default)
40+
public async Task StartAsync(
41+
int port,
42+
LocalApiServerStartOptions? options = null,
43+
CancellationToken cancellationToken = default)
4044
{
4145
if (port is <= 0 or > 65535)
4246
{
@@ -56,6 +60,8 @@ public async Task StartAsync(int port, CancellationToken cancellationToken = def
5660
await StopInternalAsync();
5761
}
5862

63+
_startOptions = options ?? new LocalApiServerStartOptions();
64+
5965
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
6066
{
6167
ApplicationName = typeof(LocalApiServer).Assembly.FullName,
@@ -64,17 +70,24 @@ public async Task StartAsync(int port, CancellationToken cancellationToken = def
6470
builder.Services.AddOpenApi();
6571

6672
builder.WebHost.UseKestrel();
67-
builder.WebHost.UseUrls($"http://127.0.0.1:{port}");
68-
builder.WebHost.UseUrls($"http://localhost:{port}");
69-
builder.WebHost.UseUrls($"http://winhost:{port}");
73+
if (_startOptions.BindLocalOnly)
74+
{
75+
builder.WebHost.UseUrls($"http://127.0.0.1:{port}");
76+
builder.WebHost.UseUrls($"http://localhost:{port}");
77+
builder.WebHost.UseUrls($"http://winhost:{port}");
78+
}
79+
else
80+
{
81+
builder.WebHost.UseUrls($"http://0.0.0.0:{port}");
82+
}
7083

7184
var app = builder.Build();
7285
MapEndpoints(app);
7386
await app.StartAsync(cancellationToken);
7487
_app = app;
7588
Port = port;
7689

77-
_logger.Info("Api", $"HTTP API started (localhost-only) port={port}");
90+
_logger.Info("Api", $"HTTP API started ({_startOptions.BindLocalOnlyLabel}) port={port}");
7891
}
7992
finally
8093
{
@@ -178,7 +191,7 @@ private void MapEndpoints(WebApplication app)
178191
port = Port,
179192
running = IsRunning,
180193
utcNow = DateTimeOffset.UtcNow,
181-
bindScope = "localhost-only (+winhost)"
194+
bindScope = _startOptions.BindLocalOnlyLabel
182195
}));
183196

184197
app.MapPost("/api/v1/app/shutdown", () =>
@@ -681,8 +694,13 @@ JsonValueKind.Number when id.TryGetDecimal(out var d) => d,
681694
};
682695
}
683696

684-
private static bool IsAllowedRequestHost(string? host)
697+
private bool IsAllowedRequestHost(string? host)
685698
{
699+
if (!_startOptions.BindLocalOnly)
700+
{
701+
return true;
702+
}
703+
686704
if (string.IsNullOrWhiteSpace(host))
687705
{
688706
return false;
@@ -692,7 +710,7 @@ private static bool IsAllowedRequestHost(string? host)
692710
return h is "localhost" or "127.0.0.1" or "::1" or "[::1]" or "winhost";
693711
}
694712

695-
private static bool TryNormalizeAllowedOrigin(string origin, out string normalized)
713+
private bool TryNormalizeAllowedOrigin(string origin, out string normalized)
696714
{
697715
normalized = string.Empty;
698716
if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri))
@@ -706,7 +724,14 @@ private static bool TryNormalizeAllowedOrigin(string origin, out string normaliz
706724
return false;
707725
}
708726

709-
if (!IsAllowedRequestHost(uri.Host))
727+
if (!_startOptions.AllowRemoteOrigins)
728+
{
729+
if (!IsAllowedRequestHost(uri.Host))
730+
{
731+
return false;
732+
}
733+
}
734+
else if (string.IsNullOrWhiteSpace(uri.Host))
710735
{
711736
return false;
712737
}
@@ -741,3 +766,11 @@ private sealed class McpRpcRequest
741766
public JsonElement Id { get; set; }
742767
}
743768
}
769+
770+
public sealed class LocalApiServerStartOptions
771+
{
772+
public bool BindLocalOnly { get; init; } = true;
773+
public bool AllowRemoteOrigins { get; init; }
774+
775+
public string BindLocalOnlyLabel => BindLocalOnly ? "localhost-only (+winhost)" : "all interfaces";
776+
}

Dockerfile

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
2+
WORKDIR /src
3+
4+
COPY AiyoPerps.slnx ./
5+
COPY AiyoPerps/AiyoPerps.csproj AiyoPerps/
6+
RUN dotnet restore AiyoPerps.slnx -p:RestoreAdditionalProjectFallbackFolders=
7+
8+
COPY . .
9+
RUN dotnet publish AiyoPerps/AiyoPerps.csproj \
10+
-c Release \
11+
-o /app/publish \
12+
-p:RestoreAdditionalProjectFallbackFolders=
13+
14+
FROM mcr.microsoft.com/dotnet/aspnet:10.0
15+
WORKDIR /app
16+
17+
ENV AIYOPERPS_HTTP_PORT=5078
18+
EXPOSE 5078
19+
20+
COPY --from=build /app/publish ./
21+
22+
VOLUME ["/app/db"]
23+
24+
ENTRYPOINT ["dotnet", "AiyoPerps.dll", "headless"]

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ The user interface currently supports Traditional Chinese and English, with inst
99
<br>
1010
![Software interface](./images/main-en-01.jpg)
1111

12+
### Support us
13+
If you think this project is cool, feel free to [**buy us a coffee**](https://tuunote.com/AiyoPerps/donate)!<br>
14+
**When you sponsor us, you’ll also get a chance to join Taiwan’s receipt lottery.**<br>
15+
That’s because [Chen-Si Studio](https://utunote.com) is a team that pays taxes properly—we issue an official receipt/invoice for every bit of income, and those invoice numbers can be checked for prizes every two months.<br>
16+
Even if you don’t live in Taiwan, if your invoice wins, just let us know. We’ll claim the prize for you and then send you the money (after deducting any necessary handling fees).
17+
1218
## 1. Requirements
1319
- Windows or Linux.
1420
- .NET 10 Runtime (included in the pre-release build).

Readme_zh.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ AiyoPerps 是一套加密貨幣永續合約操作軟體,同時支援 DEX (去
99
<br>
1010
![Software interface](./images/main-zh-01.jpg)
1111

12+
### 支持我們
13+
如果你覺得這個專案不錯,[**請我們喝杯咖啡**](https://tuunote.com/AiyoPerps/donate)吧!<br>
14+
**贊助我們的同時,你也會獲得參加台灣發票樂透的機會!**<br>
15+
因為[宸泗工作室](https://utunote.com)是誠實納稅的團隊,我們的每一筆收入都會開立發票,發票號碼每兩個月可以兌獎一次。<br>
16+
即使你不是台灣的居民,如果你中獎了,請通知我們。我們會幫你兌獎之後(扣除必要的手續費)匯款給你。
17+
1218
## 1. 環境需求
1319
- Windows 或 Linux 。
1420
- .NET 10 Runtime(Pre-Release 版本已自帶)。

images/SocialPreview.png

2.16 MB
Loading

images/SocialPreview2.jpg

123 KB
Loading

0 commit comments

Comments
 (0)