diff --git a/README.md b/README.md index ea4a1bb..c24b804 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,154 @@ ![GitHub Release](https://img.shields.io/github/v/release/UniScanProject/UniScan?sort=date&filter=UniScan.Server.Host%2F*&display_name=tag&label=Server) -Modular scanner/radio client and multi-broadcast server. Plug in a scanner, set it up in the server software, and open +Modular, cross-platform scanner/radio client and multi-broadcast server. Plug in a scanner, set it up in the server software, and open it up to the internet for others to listen to, and control it yourself by logging into an admin user. +Modular meaning that custom built modules will be able to fully extend the client and server via hooks, event listening, configuration stages, and more all without requiring e.g. Harmony hooks for most simple extension needs. + +The client works by connecting to a "Remote", which contains devices that the client can 'subscribe' to receive updates, see current scanning info, and (soon) listen to any device's configured audio track(s). + +Remotes require a plugin that adds support for whatever devices plan to be connected, with device pages being rendered on the serverside and sent to the client. +This allows the client to not require too much special setup to communicate with virtually any Remote. + +In the future, there will be easy ways for hosters to embed the UniScan client in their website with a preset configuration to automatically add and connect to their server(s). + + + + + + +
+ UniScan receiving and displaying data from a remote SDS100 +
+ UniScan receiving and displaying data from a remote SDS100 +
+ UniScan connected to a +
+ UniScan connected to a "remote" that has one device +
+ +### Roadmap +Our full roadmap can be viewed on our [GitHub Project](https://github.com/orgs/UniScanProject/projects/2) + +#### (rough) Feature roadmap +- [X] Module support +- Official Extension Modules + - [ ] Virtual Scanner debug module + - [ ] Transcript module (via some STT model) + +##### Server +- [X] Modular custom scanner support framework +- [X] Networking +- [X] Support for multiple network hosting methods (TCP, Websocket (via module)) +- [X] Serverside rendering of scanner info to send to the client +- [ ] Serverside rendering of server,serverside plugin options to send to the client +- [ ] Serverside rendering of a scanner's display to send to the client +- [ ] Custom serverside rendered pages to send to the Client +- [ ] Scanner audio track streaming + - By audio track, I mean having multiple audio tracks, since I believe some radio devices can have multiple outputs? I don't fully know yet. + - Either way, no reason to not have multiple. +- [ ] Users and Authentication +- [ ] Announcement posting from users +- [ ] Remote scanner management from users with the correct permissions +- [ ] Scanner programming (may not happen) + +##### Client +- [X] Connecting to remotes +- [X] Displaying remote info (name,description,links,logo,announcements,etc) +- [X] Displaying live device info (such as current channel, scan list, etc) +- [ ] Listening to audio tracks +- [ ] Input optimized UI + - i.e. better UI for tv remotes,controllers on Android TV, better UI for narrow touchscreens on Android,iOS. + - But all will be accessible crossplatform anyway +- [ ] Nicer theme +- [ ] User login +- [ ] Rendering of custom serverside rendered pages created by the server +- [ ] Internal server for connecting local scanners without the need for hosting + +## Setup +UniScan currently supports Windows, macOS, Linux, Android, and iOS (kind of) +In addition to these platforms, UniScan can also run [entirely in your browser!](https://uniscan.dexrn.me) + +> [!IMPORTANT] +> Browser support is currently broken due to some OPFS garbage, sorry. +> +> will fix soon hopefully. + +Additionally, Android TV does work, but no optimized UI has been made as of now. + +Below, you can learn how to setup and install UniScan for your platform. +#### Windows +- [Install .NET 10](https://dot.net/download) +- Download the latest Windows release from the releases tab (or via GitHub Actions artifacts) +- Unzip it +- Open UniScan.Client.App.Platform.Desktop.exe + +#### Linux +- [Install .NET 10](https://dot.net/download) (usually via your distro's package manager) +- Download the latest Linux release from the releases tab (or via GitHub Actions artifacts) +- Unzip it +- Open a terminal cd'd to the unzipped folder +- Run `chmod +x ./UniScan.Client.App.Platform.Desktop` +- Now you can open UniScan.Client.App.Platform.Desktop + - A small oversight is that some file managers will associate e.g. Kate with .desktop files despite the file being an exec + - You may need to use the terminal to run UniScan in this case until something is figured out + +> [!NOTE] +> There is no packaging available for Linux yet (not coming until at least the first Alpha release) + +#### MacOS +- [Install .NET 10](https://dot.net/download) +- Download the latest MacOS release from the releases tab (or via GitHub Actions artifacts) + - Note that this is unpackaged due to package testing being very hard as I don't own a Mac +- Unzip it (if using Archive Manager, double click in Finder) +- Open a Terminal +- Unquarantine the folder via `xattr -dr com.apple.quarantine {Path}` +- CD into the path with the already open terminal +- run `chmod +x ./UniScan.Client.App.Platform.Desktop` +- Run `UniScan.Client.App.Platform.Desktop` + +> [!NOTE] +> There is no packaging available for MacOS yet mainly due to the fact that its almost impossible for me to test as I do not own a Mac +> Thanks Apple for locking down everything............ +> +> However, I do intend to make packaging available for all platforms during the alpha or release stage. + +#### Android +- Download the latest Android APK from the releases tab + - Also available in the GitHub Actions artifacts, but you must download the one with "signed" in the name. +- Open the file, then tap Install (in Package Installer) + - It may require you to enable "Install unknown apps" if you have never installed an APK from the app you are opening it from before + +> [!NOTE] +> UniScan is not yet available on F-Droid, but this will come soon. + +##### Android TV +> [!IMPORTANT] +> This specific install guide requires knowledge of ADB, and a device capable of running an ADB client +> +> This guide also assumes you have no other devices currently connected on the client's ADB daemon + +- Download the latest Android APK from the releases tab + - Note that depending on your tv box model and architecture, you may need the `arm` version and NOT the `arm64` version. + - You can figure this out usually by googling for your TV box's model and finding the specs which should list the CPU, then another search to find the architecture of that CPU. + - `arm32`/`armhf`/`arm-v7a` corresponds to the `arm` APK, while `arm64`/`arm-v{8+}a` corresponds to the `arm64` APK +- Enable Developer Mode via clicking the OK button on "Build Number" ~10 times on the About screen in Settings +- Go to the "Developer Options" page in Settings +- Enable "USB Debugging" and "Wireless Debugging" +- Go inside the "Wireless Debugging" page +- Press the OK button on "Pair device with pairing code" +- You should see it show the TV's local IP address, a port number, and a 'pairing code', note these down. (like in your head or something) +- On your client device, run `adb pair {TV IP address}:{TV ADB port} {TV pairing code}` +- Once it pairs, run `adb connect {TV IP address}:{TV ADB port}` +- Now, in the same folder as the UniScan APK you downloaded, run `adb install {APK file path}` + +#### iOS +> [!IMPORTANT] +> It is unknown whether the non-emulator IPA can be installed on sideloading enabled devices. + +**TODO** as I don't have access to a simulator as of writing this. + ## Subprojects | Name | Version | Description | diff --git a/repo_assets/device_page.png b/repo_assets/device_page.png new file mode 100644 index 0000000..98a17b4 Binary files /dev/null and b/repo_assets/device_page.png differ diff --git a/repo_assets/remote_page.png b/repo_assets/remote_page.png new file mode 100644 index 0000000..e1d6113 Binary files /dev/null and b/repo_assets/remote_page.png differ diff --git a/src/Audio/UniScan.Audio/UniScan.Audio.csproj b/src/Audio/UniScan.Audio/UniScan.Audio.csproj index 576f4f4..24f36dd 100644 --- a/src/Audio/UniScan.Audio/UniScan.Audio.csproj +++ b/src/Audio/UniScan.Audio/UniScan.Audio.csproj @@ -8,4 +8,8 @@ AnyCPU + + + + diff --git a/src/Client/UniScan.Client.App/Views/Remote/Device/DevicePageView.axaml b/src/Client/UniScan.Client.App/Views/Remote/Device/DevicePageView.axaml index c3a27c6..bc664de 100644 --- a/src/Client/UniScan.Client.App/Views/Remote/Device/DevicePageView.axaml +++ b/src/Client/UniScan.Client.App/Views/Remote/Device/DevicePageView.axaml @@ -7,5 +7,23 @@ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="UniScan.Client.App.Views.Remote.Device.DevicePageView" x:DataType="device:DevicePageViewModel"> - + + + + + + + + + diff --git a/src/Client/UniScan.Client.App/Views/Remote/Device/DevicePageViewModel.cs b/src/Client/UniScan.Client.App/Views/Remote/Device/DevicePageViewModel.cs index 0a34f62..c470a80 100644 --- a/src/Client/UniScan.Client.App/Views/Remote/Device/DevicePageViewModel.cs +++ b/src/Client/UniScan.Client.App/Views/Remote/Device/DevicePageViewModel.cs @@ -14,10 +14,13 @@ public class DevicePageViewModel : ViewModelBase public DeviceViewModel DeviceViewModel { get; } public SSRSlotContext SSRViewModel { get; } + + public IRemoteRootPageDeviceNavigatorProxy RemotePageNavigator { get; } - public DevicePageViewModel(DeviceViewModel deviceViewModel, IUISlotRegistry registry) + public DevicePageViewModel(DeviceViewModel deviceViewModel, IUISlotRegistry registry, IRemoteRootPageDeviceNavigatorProxy remoteNavigator) { DeviceViewModel = deviceViewModel; + RemotePageNavigator = remoteNavigator; Identifier id = new("UniScan", "ssr", "slot", "device", DeviceViewModel.Device.Identifier); if (registry.TryGet(id, out SSRSlotContext? vm))//stupid hack because for some reason it keeps creating new SSRViewModel or something weird diff --git a/src/Client/UniScan.Client.App/Views/Remote/Device/DeviceRootPageViewModel.cs b/src/Client/UniScan.Client.App/Views/Remote/Device/DeviceRootPageViewModel.cs index 3bd47bc..1ab2c24 100644 --- a/src/Client/UniScan.Client.App/Views/Remote/Device/DeviceRootPageViewModel.cs +++ b/src/Client/UniScan.Client.App/Views/Remote/Device/DeviceRootPageViewModel.cs @@ -13,11 +13,11 @@ public class DeviceRootPageViewModel : SubPagedViewModelBase, IDisposable private DevicePageViewModel _mainPage; - public DeviceRootPageViewModel(DeviceViewModel deviceViewModel, IUISlotRegistry registry) : base(new EmptyPageViewModel(), UniScanApp.Identifier.Derived("view_model", "device", deviceViewModel.Device.Identifier)) + public DeviceRootPageViewModel(DeviceViewModel deviceViewModel, IUISlotRegistry registry, IRemoteRootPageDeviceNavigatorProxy remoteNavigator) : base(new EmptyPageViewModel(), UniScanApp.Identifier.Derived("view_model", "device", deviceViewModel.Device.Identifier)) { DeviceViewModel = deviceViewModel; - _mainPage = new DevicePageViewModel(deviceViewModel, registry); + _mainPage = new DevicePageViewModel(deviceViewModel, registry, remoteNavigator); CurrentSubpage = _mainPage; } diff --git a/src/Client/UniScan.Client.App/Views/Remote/Device/DeviceViewModel.cs b/src/Client/UniScan.Client.App/Views/Remote/Device/DeviceViewModel.cs index 100a633..a4c420c 100644 --- a/src/Client/UniScan.Client.App/Views/Remote/Device/DeviceViewModel.cs +++ b/src/Client/UniScan.Client.App/Views/Remote/Device/DeviceViewModel.cs @@ -15,10 +15,10 @@ public class DeviceViewModel : ViewModelBase, IDisposable public DeviceRootPageViewModel RootPageViewModel { get; } - public DeviceViewModel(RemoteDevice device, IUIBindingsRegistry bindingsRegistry, IUISlotRegistry slotRegistry) + public DeviceViewModel(RemoteDevice device, IUIBindingsRegistry bindingsRegistry, IUISlotRegistry slotRegistry, IRemoteRootPageDeviceNavigatorProxy remoteNavigator) { Device = device; - RootPageViewModel = new DeviceRootPageViewModel(this, slotRegistry); + RootPageViewModel = new DeviceRootPageViewModel(this, slotRegistry, remoteNavigator); bindingsRegistry.RegisterDict(Device.States).AddTo(_disposables); } diff --git a/src/Client/UniScan.Client.App/Views/Remote/RemoteInfoControl.axaml b/src/Client/UniScan.Client.App/Views/Remote/RemoteInfoControl.axaml index a6dac73..9978db8 100644 --- a/src/Client/UniScan.Client.App/Views/Remote/RemoteInfoControl.axaml +++ b/src/Client/UniScan.Client.App/Views/Remote/RemoteInfoControl.axaml @@ -15,7 +15,7 @@ - + diff --git a/src/Client/UniScan.Client.App/Views/Remote/RemoteInfoControlViewModel.cs b/src/Client/UniScan.Client.App/Views/Remote/RemoteInfoControlViewModel.cs index 331550f..bb56ad7 100644 --- a/src/Client/UniScan.Client.App/Views/Remote/RemoteInfoControlViewModel.cs +++ b/src/Client/UniScan.Client.App/Views/Remote/RemoteInfoControlViewModel.cs @@ -1,14 +1,17 @@ using System.Collections.Generic; using System.Linq; +using R3; using UniScan.Client.App.Views.ViewModel; using UniScan.Network.Data.Info.Remote; namespace UniScan.Client.App.Views.Remote; -public class RemoteInfoControlViewModel(RemoteInfo info) : ViewModelBase +//TODO this is done in a stupid way but rushing to get this done asap +public class RemoteInfoControlViewModel(RemoteInfo info, string fallbackDisplayName) : ViewModelBase { public RemoteInfo Info { get; } = info; - + public string DisplayName => !string.IsNullOrWhiteSpace(Info.DisplayName) ? Info.DisplayName : fallbackDisplayName; + public List Links { get; set; } = - [.. info.Branding.Links.Select(l => new RemoteLinkControlViewModel(l))]; + [.. info.Branding.Links.Where(l => l != default).Select(l => new RemoteLinkControlViewModel(l))]; } \ No newline at end of file diff --git a/src/Client/UniScan.Client.App/Views/Remote/RemoteLinkControl.axaml b/src/Client/UniScan.Client.App/Views/Remote/RemoteLinkControl.axaml index b0634fd..0178753 100644 --- a/src/Client/UniScan.Client.App/Views/Remote/RemoteLinkControl.axaml +++ b/src/Client/UniScan.Client.App/Views/Remote/RemoteLinkControl.axaml @@ -4,6 +4,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia" xmlns:remote="clr-namespace:UniScan.Client.App.Views.Remote" + xmlns:properties="clr-namespace:UniScan.Client.App.UI.Properties" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="UniScan.Client.App.Views.Remote.RemoteLinkControl" x:DataType="remote:RemoteLinkControlViewModel"> @@ -12,7 +13,7 @@ - + diff --git a/src/Client/UniScan.Client.App/Views/Remote/RemoteRootPageViewModel.cs b/src/Client/UniScan.Client.App/Views/Remote/RemoteRootPageViewModel.cs index 82608a4..89203e7 100644 --- a/src/Client/UniScan.Client.App/Views/Remote/RemoteRootPageViewModel.cs +++ b/src/Client/UniScan.Client.App/Views/Remote/RemoteRootPageViewModel.cs @@ -4,6 +4,7 @@ using System.Net.Mime; using System.Threading.Tasks; using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.DependencyInjection; using ObservableCollections; @@ -11,7 +12,6 @@ using Serilog; using Shiki.Common.Identity.Slug; using Shiki.Common.Identity.Slug.Formatting.Formatters; -using Shiki.Internationalization; using Shiki.Internationalization.Component; using UniScan.Client.App.UI.ServersideRendering; using UniScan.Client.App.Views.Global; @@ -35,17 +35,17 @@ public class RemoteRootPageViewModel : SubPagedViewModelBase, IDisposable, IRemo private RemotePageViewModel? _mainPage; private readonly CompositeDisposable _disposables = new(); - - private readonly TextComponentResolverService _textComponentResolver; - public RemoteRootPageViewModel(RemoteViewModel remoteViewModel, TextComponentResolverService resolver) : base(new NotConnectedRemotePageViewModel(remoteViewModel), UniScanApp.Identifier.Derived("view_model", "remote", new Slug(remoteViewModel.Remote.Id.ToString()))) + public IRelayCommand NavigateCommand { get; } + + public RemoteRootPageViewModel(RemoteViewModel remoteViewModel) : base(new NotConnectedRemotePageViewModel(remoteViewModel), UniScanApp.Identifier.Derived("view_model", "remote", new Slug(remoteViewModel.Remote.Id.ToString()))) { - _textComponentResolver = resolver; _notConnectedPage = (NotConnectedRemotePageViewModel)CurrentSubpage; RemoteViewModel = remoteViewModel; RemoteViewModel.Remote.ConnectionStatus.AsObservable().Skip(1).Subscribe(OnConnectionStateChanged).AddTo(_disposables); + NavigateCommand = new RelayCommand(((IRemoteRootPageDeviceNavigatorProxy)this).Navigate); } private void TearDown() @@ -60,8 +60,14 @@ public void Dispose() TearDown(); } - void IRemoteRootPageDeviceNavigatorProxy.Navigate(RemoteDevice device) + void IRemoteRootPageDeviceNavigatorProxy.Navigate(RemoteDevice? device) { + if (device == null) + { + Dispatcher.UIThread.Post(() => { CurrentSubpage = (ObservableObject?)_mainPage ?? _notConnectedPage; }); + return; + } + DeviceViewModel? p = RemoteViewModel.DevicesView.FirstOrDefault(page => page.Device.Identifier == device.Identifier); if (p != null) { @@ -139,5 +145,7 @@ private void OnConnectionStateChanged(IConnectionStatusContext ctx) public interface IRemoteRootPageDeviceNavigatorProxy { - void Navigate(RemoteDevice device); + IRelayCommand NavigateCommand { get; } + + void Navigate(RemoteDevice? device); } \ No newline at end of file diff --git a/src/Client/UniScan.Client.App/Views/Remote/RemoteViewModel.cs b/src/Client/UniScan.Client.App/Views/Remote/RemoteViewModel.cs index 56b7acc..265e946 100644 --- a/src/Client/UniScan.Client.App/Views/Remote/RemoteViewModel.cs +++ b/src/Client/UniScan.Client.App/Views/Remote/RemoteViewModel.cs @@ -9,7 +9,6 @@ using R3; using Shiki.Common.Identity.Slug; using Shiki.Common.Identity.Slug.Formatting.Formatters; -using Shiki.Internationalization; using Shiki.Internationalization.Component; using UniScan.Client.App.Core.Helpers; using UniScan.Client.App.UI.ServersideRendering; @@ -53,17 +52,17 @@ public RemoteViewModel(RemoteServer remote, IServiceProvider provider) Pipeline = new RemoteConnectionPipeline(); _serviceProvider = provider; - RootPageViewModel = new RemoteRootPageViewModel(this, provider.GetRequiredService()); + RootPageViewModel = new RemoteRootPageViewModel(this); IUIBindingsRegistry bindingsRegistry = provider.GetRequiredService(); IUISlotRegistry slotRegistry = provider.GetRequiredService(); - _deviceList = new SynchronizedViewMirror, RemoteDevice>, DeviceViewModel>(Remote.Devices.CreateView(kvp => new DeviceViewModel(kvp.Value, bindingsRegistry, slotRegistry))); + _deviceList = new SynchronizedViewMirror, RemoteDevice>, DeviceViewModel>(Remote.Devices.CreateView(kvp => new DeviceViewModel(kvp.Value, bindingsRegistry, slotRegistry, RootPageViewModel))); DevicesView = _deviceList.Output; - Remote.RemoteInfo.AsObservable().Subscribe((v) => + Remote.RemoteInfo.AsObservable().Subscribe(v => { - RemoteInfoControlViewModel? n = v != null ? new RemoteInfoControlViewModel(v) : null; + RemoteInfoControlViewModel? n = v != null ? new RemoteInfoControlViewModel(v, Remote.DisplayName.Value) : null; InfoViewModelStream.OnNext(n); OnPropertyChanged(nameof(InfoViewModel)); diff --git a/src/Client/UniScan.Client.Core/Config/Remote/RemoteDto.cs b/src/Client/UniScan.Client.Core/Config/Remote/RemoteDto.cs index 5737180..c2270fe 100644 --- a/src/Client/UniScan.Client.Core/Config/Remote/RemoteDto.cs +++ b/src/Client/UniScan.Client.Core/Config/Remote/RemoteDto.cs @@ -6,11 +6,14 @@ namespace UniScan.Client.Core.Config.Remote; [method: JsonConstructor] -public record RemoteCacheDto( - [property: JsonPropertyName("remote_info")] - RemoteInfo? RemoteInfo) +public record CachedRemoteInfoDto( + [property: JsonPropertyName("display_name")] string? DisplayName, + [property: JsonPropertyName("logo_url")] Uri? LogoUrl) { - public static RemoteCacheDto FromRemoteServer(RemoteServer remoteServer) => new(remoteServer.RemoteInfo.Value); + public static CachedRemoteInfoDto? FromRemoteServer(RemoteServer remoteServer) => remoteServer.RemoteInfo.Value != null ? new CachedRemoteInfoDto(remoteServer.RemoteInfo.Value.DisplayName, remoteServer.RemoteInfo.Value.Branding.LogoUrl) : null; + + //TODO replace + public RemoteInfo ToSimpleRemoteInfo() => new(DisplayName, null, new RemoteBranding(LogoUrl, []), new RemoteSocial()); } [method: JsonConstructor] diff --git a/src/Client/UniScan.Client.Core/DI/Factory/RemoteFactory.cs b/src/Client/UniScan.Client.Core/DI/Factory/RemoteFactory.cs index c8bd5d0..cff1245 100644 --- a/src/Client/UniScan.Client.Core/DI/Factory/RemoteFactory.cs +++ b/src/Client/UniScan.Client.Core/DI/Factory/RemoteFactory.cs @@ -14,11 +14,11 @@ public interface IRemoteFactory { public RemoteServer Create(Guid id, IRemoteConnectionMethod connectionMethod); - public RemoteServer Create(Guid id, RemoteDto dto, RemoteCacheDto? cache); + public RemoteServer Create(Guid id, RemoteDto dto, CachedRemoteInfoDto? cache); } public class RemoteFactory(IServiceProvider provider) : IRemoteFactory { public RemoteServer Create(Guid id, IRemoteConnectionMethod connectionMethod) => new(id, connectionMethod, provider.GetRequiredService()); - public RemoteServer Create(Guid id, RemoteDto dto, RemoteCacheDto? cache) => new(id, dto, cache, provider.GetRequiredService()); + public RemoteServer Create(Guid id, RemoteDto dto, CachedRemoteInfoDto? cache) => new(id, dto, cache, provider.GetRequiredService()); } \ No newline at end of file diff --git a/src/Client/UniScan.Client.Core/Remote/Device/RemoteDevice.cs b/src/Client/UniScan.Client.Core/Remote/Device/RemoteDevice.cs index ecd4f80..f7c16c8 100644 --- a/src/Client/UniScan.Client.Core/Remote/Device/RemoteDevice.cs +++ b/src/Client/UniScan.Client.Core/Remote/Device/RemoteDevice.cs @@ -17,6 +17,8 @@ public class RemoteDevice(Slug id, string? name, bool connec public Slug Identifier { get; } = id; public string? DisplayName { get; } = name; + public string Name => DisplayName ?? Identifier.Value; + public bool Connected { get; } = connected; public RemoteServer Parent { get; } = parent; diff --git a/src/Client/UniScan.Client.Core/Remote/RemoteServer.cs b/src/Client/UniScan.Client.Core/Remote/RemoteServer.cs index 1a88ae1..f56d496 100644 --- a/src/Client/UniScan.Client.Core/Remote/RemoteServer.cs +++ b/src/Client/UniScan.Client.Core/Remote/RemoteServer.cs @@ -38,7 +38,7 @@ public interface IRemoteServerMutationProxy public class RemoteServer : IRemoteServerMutationProxy { - public IReadOnlyBindableReactiveProperty DisplayName { get; } + public IReadOnlyBindableReactiveProperty DisplayName { get; } public Guid Id { get; } @@ -95,7 +95,7 @@ public RemoteServer(Guid id, IRemoteConnectionMethod connectionMethod, IClientSo _remoteInfo.Value = info; } - public RemoteServer(Guid id, RemoteDto dto, RemoteCacheDto? cache, IClientSocketFactory socketFactory) : this(id, dto.ConnectionMethod, socketFactory, cache?.RemoteInfo) {} + public RemoteServer(Guid id, RemoteDto dto, CachedRemoteInfoDto? cache, IClientSocketFactory socketFactory) : this(id, dto.ConnectionMethod, socketFactory, cache?.ToSimpleRemoteInfo()) {} void IRemoteServerMutationProxy.SetSoftwareInfo(ServerSoftwareInfo info) { diff --git a/src/Client/UniScan.Client.Core/Remote/RemoteStorage.cs b/src/Client/UniScan.Client.Core/Remote/RemoteStorage.cs index d655e47..f345a87 100644 --- a/src/Client/UniScan.Client.Core/Remote/RemoteStorage.cs +++ b/src/Client/UniScan.Client.Core/Remote/RemoteStorage.cs @@ -18,7 +18,7 @@ public class RemoteStorage( IRemoteManager remoteManager, IRemoteFactory remoteFactory, DirectoryKeyValueStorage remoteStorage, - DirectoryKeyValueStorage? cacheStorage + DirectoryKeyValueStorage? cacheStorage ) : IRemoteStorage { private readonly CompositeDisposable _disposables = new(); @@ -50,7 +50,7 @@ public async Task LoadAsync() continue; } - caches.TryGetValue(id.ToString(), out RemoteCacheDto? cache); + caches.TryGetValue(id.ToString(), out CachedRemoteInfoDto? cache); remoteManager.Remotes.Add(remoteFactory.Create(id, dto.Value, cache)); } @@ -84,7 +84,7 @@ private async Task SaveCacheAsync(RemoteServer remote) if (cacheStorage == null) return; - RemoteCacheDto dto = RemoteCacheDto.FromRemoteServer(remote); + CachedRemoteInfoDto dto = CachedRemoteInfoDto.FromRemoteServer(remote); await cacheStorage.SaveAsync(remote.Id.ToString(), dto); } diff --git a/src/Client/UniScan.Client.Core/UniScanClient.DependencyInjection.cs b/src/Client/UniScan.Client.Core/UniScanClient.DependencyInjection.cs index c8255de..81c3387 100644 --- a/src/Client/UniScan.Client.Core/UniScanClient.DependencyInjection.cs +++ b/src/Client/UniScan.Client.Core/UniScanClient.DependencyInjection.cs @@ -65,12 +65,12 @@ public static async Task AddUniScanClientAsync(this IService new JsonStorageSerializer(provider.GetRequiredKeyedService("PolymorphicJsonOptions")) )); - services.AddSingleton>(provider => - new DirectoryKeyValueStorage( + services.AddSingleton>(provider => + new DirectoryKeyValueStorage( Path.Combine(paths.CachePath, "remotes"), directoryManager, fileManager, - new JsonStorageSerializer(provider.GetRequiredKeyedService("PolymorphicJsonOptions")) + new JsonStorageSerializer(provider.GetRequiredKeyedService("PolymorphicJsonOptions")) )); services.AddSingleton(); diff --git a/src/Network/UniScan.Network/Data/Info/Remote/RemoteBranding.cs b/src/Network/UniScan.Network/Data/Info/Remote/RemoteBranding.cs index 3e89fd8..93539b7 100644 --- a/src/Network/UniScan.Network/Data/Info/Remote/RemoteBranding.cs +++ b/src/Network/UniScan.Network/Data/Info/Remote/RemoteBranding.cs @@ -1,9 +1,21 @@ +using System.ComponentModel; +using System.Text.Json.Serialization; using MessagePack; namespace UniScan.Network.Data.Info.Remote; +/// +/// Remote branding settings +/// +/// The remote's logo URL, if present +/// A list of links to external websites [MessagePackObject] public readonly record struct RemoteBranding( - [property: Key(0)] Uri? LogoUrl, - [property: Key(1)] List Links -); \ No newline at end of file + [property: Key(0), JsonPropertyName("logo_url")] + Uri? LogoUrl, + [property: Key(1), JsonPropertyName("links")] + List Links +) +{ + public RemoteBranding() : this(null, []) { } +} \ No newline at end of file diff --git a/src/Network/UniScan.Network/Data/Info/Remote/RemoteInfo.cs b/src/Network/UniScan.Network/Data/Info/Remote/RemoteInfo.cs index 482df9f..71ecb83 100644 --- a/src/Network/UniScan.Network/Data/Info/Remote/RemoteInfo.cs +++ b/src/Network/UniScan.Network/Data/Info/Remote/RemoteInfo.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Text.Json.Serialization; using MessagePack; using Shiki.Common.Identity; using Shiki.Internationalization.Component; @@ -15,8 +16,8 @@ namespace UniScan.Network.Data.Info.Remote; /// Social stuff (announcements,motd) [MessagePackObject] public record RemoteInfo( - [property: Key(0)] string DisplayName, - [property: Key(1)] ITextComponent? Description, - [property: Key(2)] RemoteBranding Branding, - [property: Key(3)] RemoteSocial Social + [property: Key(0), JsonPropertyName("display_name")] string? DisplayName, + [property: Key(1), JsonPropertyName("description")] ITextComponent? Description, + [property: Key(2), JsonPropertyName("branding"), JsonRequired] RemoteBranding Branding, + [property: Key(3), JsonPropertyName("social"), JsonRequired] RemoteSocial Social ); \ No newline at end of file diff --git a/src/Network/UniScan.Network/Data/Info/Remote/RemoteLink.cs b/src/Network/UniScan.Network/Data/Info/Remote/RemoteLink.cs index d4f209e..6ae1677 100644 --- a/src/Network/UniScan.Network/Data/Info/Remote/RemoteLink.cs +++ b/src/Network/UniScan.Network/Data/Info/Remote/RemoteLink.cs @@ -1,11 +1,18 @@ +using System.Text.Json.Serialization; using MessagePack; using Shiki.Internationalization.Component; namespace UniScan.Network.Data.Info.Remote; +/// +/// A link to an external website +/// +/// The icon url, if present +/// The link title +/// The link itself [MessagePackObject] public readonly record struct RemoteLink( - [property: Key(0)] Uri? IconUrl, - [property: Key(1)] ITextComponent Name, - [property: Key(2)] Uri Url + [property: Key(0), JsonPropertyName("icon_url")] Uri? IconUrl, + [property: Key(1), JsonPropertyName("name")] ITextComponent Name, + [property: Key(2), JsonPropertyName("url")] Uri Url ); \ No newline at end of file diff --git a/src/Network/UniScan.Network/Data/Info/Remote/RemoteSocial.cs b/src/Network/UniScan.Network/Data/Info/Remote/RemoteSocial.cs index 9572641..ffb5679 100644 --- a/src/Network/UniScan.Network/Data/Info/Remote/RemoteSocial.cs +++ b/src/Network/UniScan.Network/Data/Info/Remote/RemoteSocial.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using MessagePack; using MessagePack.Formatters; using Shiki.Common.Identity; @@ -10,6 +11,6 @@ namespace UniScan.Network.Data.Info.Remote; [MessagePackObject] public readonly record struct RemoteSocial( - [property: Key(0)] ITextComponent? MessageOfTheDay, - [property: Key(1)] IReadOnlyDictionary, RemoteAnnouncement> Announcements//TODO make this list of IDs, so that we aren't sending such a large packet if there are many announcements + [property: Key(0), JsonPropertyName("motd")] ITextComponent? MessageOfTheDay, + [property: Key(1), JsonPropertyName("announcements")] IReadOnlyDictionary, RemoteAnnouncement> Announcements//TODO make this list of IDs, so that we aren't sending such a large packet if there are many announcements ); \ No newline at end of file diff --git a/src/Server/UniScan.Server.Core/Social/RemoteInfoManager.cs b/src/Server/UniScan.Server.Core/Social/RemoteInfoManager.cs index ff7671c..b6dda98 100644 --- a/src/Server/UniScan.Server.Core/Social/RemoteInfoManager.cs +++ b/src/Server/UniScan.Server.Core/Social/RemoteInfoManager.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Collections.Immutable; +using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Serialization; using ObservableCollections; @@ -29,7 +30,8 @@ public class RemoteInfoManager public StoredObject Metadata { get; } public StoredObject Branding { get; } - public RemoteInfo GetRemoteInfo() => new RemoteInfo(Metadata.Property.Value.ServerName, Metadata.Value.ServerDescription, Branding.Value, new RemoteSocial(Metadata.Value.MessageOfTheDay, Announcements.Value)); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public RemoteInfo GetRemoteInfo() => new(Metadata.Property.Value.ServerName, Metadata.Value.ServerDescription, Branding.Value, new RemoteSocial(Metadata.Value.MessageOfTheDay, Announcements.Value)); public RemoteInfoManager(string directory, IPlatformFileManager fileManager, IPlatformDirectoryManager directoryManager, JsonSerializerOptions? serializerOptions) { @@ -64,6 +66,7 @@ public RemoteInfoManager(string directory, IPlatformFileManager fileManager, IPl new StoredObject, RemoteAnnouncement>>([], [v => v.ObserveChanged().Select(_ => v)], m, m); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public async Task LoadAsync() { await Metadata.LoadAsync(); @@ -72,6 +75,6 @@ public async Task LoadAsync() } } -public record SocialMetadata([property: JsonPropertyName("server_name")] string ServerName, +public record SocialMetadata([property: JsonPropertyName("server_name")] string? ServerName, [property: JsonPropertyName("server_description")] ITextComponent? ServerDescription, [property: JsonPropertyName("server_motd")] ITextComponent? MessageOfTheDay); \ No newline at end of file diff --git a/src/Server/UniScan.Server.Core/UniScanServer.cs b/src/Server/UniScan.Server.Core/UniScanServer.cs index 22509c7..3206b4c 100755 --- a/src/Server/UniScan.Server.Core/UniScanServer.cs +++ b/src/Server/UniScan.Server.Core/UniScanServer.cs @@ -1,52 +1,25 @@ -using System.Collections.Immutable; -using System.Reflection; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Schema; +using System.Reflection; using System.Text.Json.Serialization; -using Microsoft.Extensions.DependencyInjection; using Semver; using Serilog; -using Serilog.Core; using Shiki.Common.Extensions; using Shiki.Common.Identity; -using Shiki.Common.Identity.Slug; -using Shiki.Common.Identity.Slug.Formatting.Formatters; -using Shiki.Common.Util; -using Shiki.FileSystem.Implementations.Assembly; -using Shiki.FileSystem.Storage; -using Shiki.FileSystem.Storage.Directory.KeyValue; -using Shiki.FileSystem.Storage.Serializer; -using Shiki.Internationalization; using Shiki.Internationalization.Component; -using Shiki.Internationalization.Component.Resolver; -using Shiki.Internationalization.Translation; -using Shiki.Internationalization.Translation.LanguageSource; using Shiki.ModuleManagement; -using Shiki.TaskPipeline.Context; using Shiki.Settings; using Shiki.Settings.Option; using UniScan.Audio.Server; -using UniScan.Core.State; -using UniScan.Device.Device; -using UniScan.Network; -using UniScan.Network.Data.Info.Remote; using UniScan.Network.Data.Info.Software; using UniScan.Network.Group; using UniScan.Network.Protocol.Packets.Bidirectional.Status; using UniScan.Network.Registry; -using UniScan.Network.Registry.Source.Sources; using UniScan.Network.Server; using UniScan.Network.Server.HostMethod; using UniScan.Network.Socket; -using UniScan.Network.Socket.Configuration; using UniScan.Server.Authentication.Session; using UniScan.Server.Core.Host; using UniScan.Server.Core.Module; -using UniScan.Server.Core.Pipeline; using UniScan.Server.Core.Social; -using UniScan.Server.Core.UserInterface; -using Constants = UniScan.Core.Constants; namespace UniScan.Server.Core; diff --git a/template/README.md.template b/template/README.md.template index 36f89e2..3b16db0 100644 --- a/template/README.md.template +++ b/template/README.md.template @@ -6,9 +6,154 @@ ![GitHub Release](https://img.shields.io/github/v/release/UniScanProject/UniScan?sort=date&filter=UniScan.Server.Host%2F*&display_name=tag&label=Server) -Modular scanner/radio client and multi-broadcast server. Plug in a scanner, set it up in the server software, and open +Modular, cross-platform scanner/radio client and multi-broadcast server. Plug in a scanner, set it up in the server software, and open it up to the internet for others to listen to, and control it yourself by logging into an admin user. +Modular meaning that custom built modules will be able to fully extend the client and server via hooks, event listening, configuration stages, and more all without requiring e.g. Harmony hooks for most simple extension needs. + +The client works by connecting to a "Remote", which contains devices that the client can 'subscribe' to receive updates, see current scanning info, and (soon) listen to any device's configured audio track(s). + +Remotes require a plugin that adds support for whatever devices plan to be connected, with device pages being rendered on the serverside and sent to the client. +This allows the client to not require too much special setup to communicate with virtually any Remote. + +In the future, there will be easy ways for hosters to embed the UniScan client in their website with a preset configuration to automatically add and connect to their server(s). + + + + + + +
+ UniScan receiving and displaying data from a remote SDS100 +
+ UniScan receiving and displaying data from a remote SDS100 +
+ UniScan connected to a +
+ UniScan connected to a "remote" that has one device +
+ +### Roadmap +Our full roadmap can be viewed on our [GitHub Project](https://github.com/orgs/UniScanProject/projects/2) + +#### (rough) Feature roadmap +- [X] Module support +- Official Extension Modules + - [ ] Virtual Scanner debug module + - [ ] Transcript module (via some STT model) + +##### Server +- [X] Modular custom scanner support framework +- [X] Networking +- [X] Support for multiple network hosting methods (TCP, Websocket (via module)) +- [X] Serverside rendering of scanner info to send to the client +- [ ] Serverside rendering of server,serverside plugin options to send to the client +- [ ] Serverside rendering of a scanner's display to send to the client +- [ ] Custom serverside rendered pages to send to the Client +- [ ] Scanner audio track streaming + - By audio track, I mean having multiple audio tracks, since I believe some radio devices can have multiple outputs? I don't fully know yet. + - Either way, no reason to not have multiple. +- [ ] Users and Authentication +- [ ] Announcement posting from users +- [ ] Remote scanner management from users with the correct permissions +- [ ] Scanner programming (may not happen) + +##### Client +- [X] Connecting to remotes +- [X] Displaying remote info (name,description,links,logo,announcements,etc) +- [X] Displaying live device info (such as current channel, scan list, etc) +- [ ] Listening to audio tracks +- [ ] Input optimized UI + - i.e. better UI for tv remotes,controllers on Android TV, better UI for narrow touchscreens on Android,iOS. + - But all will be accessible crossplatform anyway +- [ ] Nicer theme +- [ ] User login +- [ ] Rendering of custom serverside rendered pages created by the server +- [ ] Internal server for connecting local scanners without the need for hosting + +## Setup +UniScan currently supports Windows, macOS, Linux, Android, and iOS (kind of) +In addition to these platforms, UniScan can also run [entirely in your browser!](https://uniscan.dexrn.me) + +> [!IMPORTANT] +> Browser support is currently broken due to some OPFS garbage, sorry. +> +> will fix soon hopefully. + +Additionally, Android TV does work, but no optimized UI has been made as of now. + +Below, you can learn how to setup and install UniScan for your platform. +#### Windows +- [Install .NET 10](https://dot.net/download) +- Download the latest Windows release from the releases tab (or via GitHub Actions artifacts) +- Unzip it +- Open UniScan.Client.App.Platform.Desktop.exe + +#### Linux +- [Install .NET 10](https://dot.net/download) (usually via your distro's package manager) +- Download the latest Linux release from the releases tab (or via GitHub Actions artifacts) +- Unzip it +- Open a terminal cd'd to the unzipped folder +- Run `chmod +x ./UniScan.Client.App.Platform.Desktop` +- Now you can open UniScan.Client.App.Platform.Desktop + - A small oversight is that some file managers will associate e.g. Kate with .desktop files despite the file being an exec + - You may need to use the terminal to run UniScan in this case until something is figured out + +> [!NOTE] +> There is no packaging available for Linux yet (not coming until at least the first Alpha release) + +#### MacOS +- [Install .NET 10](https://dot.net/download) +- Download the latest MacOS release from the releases tab (or via GitHub Actions artifacts) + - Note that this is unpackaged due to package testing being very hard as I don't own a Mac +- Unzip it (if using Archive Manager, double click in Finder) +- Open a Terminal +- Unquarantine the folder via `xattr -dr com.apple.quarantine \{Path\}` +- CD into the path with the already open terminal +- run `chmod +x ./UniScan.Client.App.Platform.Desktop` +- Run `UniScan.Client.App.Platform.Desktop` + +> [!NOTE] +> There is no packaging available for MacOS yet mainly due to the fact that its almost impossible for me to test as I do not own a Mac +> Thanks Apple for locking down everything............ +> +> However, I do intend to make packaging available for all platforms during the alpha or release stage. + +#### Android +- Download the latest Android APK from the releases tab + - Also available in the GitHub Actions artifacts, but you must download the one with "signed" in the name. +- Open the file, then tap Install (in Package Installer) + - It may require you to enable "Install unknown apps" if you have never installed an APK from the app you are opening it from before + +> [!NOTE] +> UniScan is not yet available on F-Droid, but this will come soon. + +##### Android TV +> [!IMPORTANT] +> This specific install guide requires knowledge of ADB, and a device capable of running an ADB client +> +> This guide also assumes you have no other devices currently connected on the client's ADB daemon + +- Download the latest Android APK from the releases tab + - Note that depending on your tv box model and architecture, you may need the `arm` version and NOT the `arm64` version. + - You can figure this out usually by googling for your TV box's model and finding the specs which should list the CPU, then another search to find the architecture of that CPU. + - `arm32`/`armhf`/`arm-v7a` corresponds to the `arm` APK, while `arm64`/`arm-v\{8+\}a` corresponds to the `arm64` APK +- Enable Developer Mode via clicking the OK button on "Build Number" ~10 times on the About screen in Settings +- Go to the "Developer Options" page in Settings +- Enable "USB Debugging" and "Wireless Debugging" +- Go inside the "Wireless Debugging" page +- Press the OK button on "Pair device with pairing code" +- You should see it show the TV's local IP address, a port number, and a 'pairing code', note these down. (like in your head or something) +- On your client device, run `adb pair \{TV IP address\}:\{TV ADB port\} \{TV pairing code\}` +- Once it pairs, run `adb connect \{TV IP address\}:\{TV ADB port\}` +- Now, in the same folder as the UniScan APK you downloaded, run `adb install \{APK file path\}` + +#### iOS +> [!IMPORTANT] +> It is unknown whether the non-emulator IPA can be installed on sideloading enabled devices. + +**TODO** as I don't have access to a simulator as of writing this. + ## Subprojects | Name | Version | Description |