diff --git a/.github/workflows/gui.yaml b/.github/workflows/gui.yaml new file mode 100644 index 0000000..0cec6a9 --- /dev/null +++ b/.github/workflows/gui.yaml @@ -0,0 +1,69 @@ +name: gui + +on: [push, pull_request] + +jobs: + + linux: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v1 + - name: Setup .NET + uses: actions/setup-dotnet@v2 + with: + dotnet-version: 6.0.x + - name: Install dependencies + working-directory: ./gui + run: dotnet restore + - name: Publish + working-directory: ./gui + run: dotnet publish -c Release -r linux-x64 --self-contained true /p:PublishSingleFile=true /p:EnableCompressionInSingleFile=true /p:IncludeNativeLibrariesForSelfExtract=true /p:PublishTrimmed=true + - name: Archive publish artifacts + uses: actions/upload-artifact@v3 + with: + name: maskcore_linux-x64 + path: gui/bin/Release/net6.0/linux-x64/publish + + win: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v1 + - name: Setup .NET + uses: actions/setup-dotnet@v2 + with: + dotnet-version: 6.0.x + - name: Install dependencies + working-directory: ./gui + run: dotnet restore + - name: Publish + working-directory: ./gui + run: dotnet publish -c Release -r win-x64 --self-contained true /p:PublishSingleFile=true /p:EnableCompressionInSingleFile=true /p:IncludeNativeLibrariesForSelfExtract=true /p:PublishTrimmed=true + - name: Archive publish artifacts + uses: actions/upload-artifact@v3 + with: + name: maskcore_win-x64 + path: gui/bin/Release/net6.0/win-x64/publish + + osx: + runs-on: macos-latest + + steps: + - uses: actions/checkout@v1 + - name: Setup .NET + uses: actions/setup-dotnet@v2 + with: + dotnet-version: 6.0.x + - name: Install dependencies + working-directory: ./gui + run: dotnet restore -r osx-x64 + - name: Publish + working-directory: ./gui + run: dotnet msbuild -t:BundleApp -p:RuntimeIdentifier=osx-x64 -property:Configuration=Release -p:UseAppHost=true -p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishTrimmed=true -p:SelfContained=true + - name: Archive publish artifacts + uses: actions/upload-artifact@v3 + with: + name: maskcore_osx-x64 + path: gui/bin/Release/net6.0/osx-x64/publish diff --git a/gui/.gitignore b/gui/.gitignore new file mode 100644 index 0000000..8afdcb6 --- /dev/null +++ b/gui/.gitignore @@ -0,0 +1,454 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# JetBrains Rider +.idea/ +*.sln.iml + +## +## Visual Studio Code +## +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json diff --git a/gui/App.axaml b/gui/App.axaml new file mode 100644 index 0000000..93f1a15 --- /dev/null +++ b/gui/App.axaml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/gui/App.axaml.cs b/gui/App.axaml.cs new file mode 100644 index 0000000..d938542 --- /dev/null +++ b/gui/App.axaml.cs @@ -0,0 +1,61 @@ +using System; +using System.IO; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using CommunityToolkit.Mvvm.DependencyInjection; +using Dimension.MaskCore.Common; +using Dimension.MaskCore.Data.Repository; +using Dimension.MaskCore.UI.Shell; +using Microsoft.Extensions.DependencyInjection; +using Realms; + +namespace Dimension.MaskCore; + +internal class App : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + EnsureWorkingDirectory(); + Ioc.Default.ConfigureServices(ConfigureServices()); + } + + private void EnsureWorkingDirectory() + { + if (!Directory.Exists(Consts.ConfigDirectory)) + { + Directory.CreateDirectory(Consts.ConfigDirectory); + } + } + + private static IServiceProvider ConfigureServices() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(_ => + Realm.GetInstance(new RealmConfiguration(Path.Combine(Consts.ConfigDirectory, ".realm")) + { ShouldDeleteIfMigrationNeeded = true })); + return services.BuildServiceProvider(); + } + + public override void OnFrameworkInitializationCompleted() + { + switch (ApplicationLifetime) + { + case IClassicDesktopStyleApplicationLifetime classicDesktopStyleApplicationLifetime: + classicDesktopStyleApplicationLifetime.MainWindow = new RootWindow(); + classicDesktopStyleApplicationLifetime.Exit += (_, _) => + { + Ioc.Default.GetRequiredService().Dispose(); + }; + break; + case ISingleViewApplicationLifetime singleViewApplicationLifetime: + singleViewApplicationLifetime.MainView = new RootShell(); + break; + } + + base.OnFrameworkInitializationCompleted(); + } +} \ No newline at end of file diff --git a/gui/Assets/favicon.ico b/gui/Assets/favicon.ico new file mode 100644 index 0000000..6c09952 Binary files /dev/null and b/gui/Assets/favicon.ico differ diff --git a/gui/Common/Consts.cs b/gui/Common/Consts.cs new file mode 100644 index 0000000..0b59e54 --- /dev/null +++ b/gui/Common/Consts.cs @@ -0,0 +1,25 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; + +namespace Dimension.MaskCore.Common; + +internal class Consts +{ + public static string ConfigDirectory + { + get + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), + "Mask"); + } + + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".mask"); + } + } +} \ No newline at end of file diff --git a/gui/Common/Extension/EnumerabsleExtension.cs b/gui/Common/Extension/EnumerabsleExtension.cs new file mode 100644 index 0000000..a6af59e --- /dev/null +++ b/gui/Common/Extension/EnumerabsleExtension.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Dimension.MaskCore.Common.Extension; + +internal static class EnumerabsleExtension +{ + public static IEnumerable Randomize(this IEnumerable source) + { + var rnd = new Random(); + return source.OrderBy(item => rnd.Next()); + } +} \ No newline at end of file diff --git a/gui/Common/Extension/RxRealmExtension.cs b/gui/Common/Extension/RxRealmExtension.cs new file mode 100644 index 0000000..a088596 --- /dev/null +++ b/gui/Common/Extension/RxRealmExtension.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Linq; +using Realms; + +namespace Dimension.MaskCore.Common.Extension; + +public static class RxRealmExtension +{ + public static IObservable> AsObservable(this IQueryable query) where T : RealmObjectBase + { + return Observable.Create>(emitter => query.SubscribeForNotifications((sender, _, error) => + { + if (error != null) + { + emitter.OnError(error); + } + else + { + emitter.OnNext(sender); + } + })); + } +} \ No newline at end of file diff --git a/gui/Common/Helpers/MnemonicHelper.cs b/gui/Common/Helpers/MnemonicHelper.cs new file mode 100644 index 0000000..49cc583 --- /dev/null +++ b/gui/Common/Helpers/MnemonicHelper.cs @@ -0,0 +1,11 @@ +using Dimension.MaskWalletCore; + +namespace Dimension.MaskCore.Common.Helpers; + +internal static class MnemonicHelper +{ + public static string GenerateMnemonic() + { + return WalletKey.GenerateMnemonic(); + } +} \ No newline at end of file diff --git a/gui/Data/Model/DbPersonaModel.cs b/gui/Data/Model/DbPersonaModel.cs new file mode 100644 index 0000000..1f74096 --- /dev/null +++ b/gui/Data/Model/DbPersonaModel.cs @@ -0,0 +1,40 @@ +using System; +using System.Text.Json; +using Dimension.MaskWalletCore; +using MongoDB.Bson; +using Realms; + +namespace Dimension.MaskCore.Data.Model; + +internal class DbPersonaModel : RealmObject +{ + [PrimaryKey] public ObjectId Id { get; set; } = ObjectId.GenerateNewId(); + [Required] public string Name { get; set; } = string.Empty; + [Required] public string Identifier { get; set; } = string.Empty; + [Required] public string Mnemonic { get; set; } = string.Empty; + [Required] public string Path { get; set; } = string.Empty; + public bool WithPassword { get; set; } + [Required] public string Password { get; set; } = string.Empty; + [Required] public string PrivateKey { get; set; } = string.Empty; + [Required] public string PublicKey { get; set; } = string.Empty; + public string? LocalKey { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + + public static DbPersonaModel FromPersona(PersonaKey persona, string mnemonic, string path, string password, + bool withPassword, string name) + { + return new DbPersonaModel + { + Name = name, + Identifier = persona.Identifier, + Mnemonic = mnemonic, + Path = path, + WithPassword = withPassword, + Password = password, + PrivateKey = JsonSerializer.Serialize(persona.PrivateKey), + PublicKey = JsonSerializer.Serialize(persona.PrivateKey with { d = null }), + LocalKey = persona.LocalKey == null ? null : JsonSerializer.Serialize(persona.LocalKey) + }; + } +} \ No newline at end of file diff --git a/gui/Data/Model/DbWalletModel.cs b/gui/Data/Model/DbWalletModel.cs new file mode 100644 index 0000000..caa8d25 --- /dev/null +++ b/gui/Data/Model/DbWalletModel.cs @@ -0,0 +1,25 @@ +using System; +using Dimension.MaskCore.Model; +using MongoDB.Bson; +using Realms; + +namespace Dimension.MaskCore.Data.Model; + +internal class DbWalletModel : RealmObject +{ + [PrimaryKey] public ObjectId Id { get; set; } = ObjectId.GenerateNewId(); + [Required] public string Name { get; set; } = string.Empty; + [Required] public string Address { get; set; } = string.Empty; + public string DerivationPath { get; set; } = string.Empty; + public byte[] Data { get; set; } = Array.Empty(); + private string PlatformTypeRaw { get; set; } = string.Empty; + + public PlatformType PlatformType + { + get => Enum.TryParse(PlatformTypeRaw, out PlatformType result) ? result : PlatformType.Ethereum; + set => PlatformTypeRaw = value.ToString(); + } + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} \ No newline at end of file diff --git a/gui/Data/Repository/PersonaRepository.cs b/gui/Data/Repository/PersonaRepository.cs new file mode 100644 index 0000000..38eaade --- /dev/null +++ b/gui/Data/Repository/PersonaRepository.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reactive.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.DependencyInjection; +using Dimension.MaskCore.Common.Extension; +using Dimension.MaskCore.Data.Model; +using Dimension.MaskCore.UI.Model; +using Dimension.MaskWalletCore; +using Realms; + +namespace Dimension.MaskCore.Data.Repository; + +internal class PersonaRepository +{ + private const string Path = "m/44'/60'/0'/0/0"; + private const string Password = ""; + + private static readonly Realm _realm = Ioc.Default.GetRequiredService(); + + public IObservable> Personas { get; } = _realm.All() + .AsObservable() + .Select(it => it.Select(UiPersonaModel.FromDb).ToImmutableList()); + + public async Task CreatePersona(string name, string mnemonic) + { + if (string.IsNullOrEmpty(mnemonic) || string.IsNullOrEmpty(name)) + { + throw new ArgumentNullException(nameof(mnemonic)); + } + + if (_realm.All().Any(it => it.Mnemonic == mnemonic)) + { + throw new ArgumentException($"Persona with name {name} already exists"); + } + + var persona = await Task.Run(() => PersonaKey.Create( + mnemonic, + Password, + Path, + CurveType.Secp256k1, + new EncryptionOption(EncryptionOption.EncVersion.V38) + )); + _realm.Write(() => { _realm.Add(DbPersonaModel.FromPersona(persona, mnemonic, Path, Password, false, name)); }); + } + + public void UpdatePersonaName(string identifier, string name) + { + var dbPersona = _realm.All().FirstOrDefault(it => it.Identifier == identifier); + if (dbPersona == null) + { + return; + } + + _realm.Write(() => { dbPersona.Name = name; }); + } + + public void DeletePersona(string identifier) + { + var item = _realm.All().FirstOrDefault(it => it.Identifier == identifier); + if (item == null) + { + return; + } + + _realm.Write(() => _realm.Remove(item)); + } +} \ No newline at end of file diff --git a/gui/Data/Repository/WalletRepository.cs b/gui/Data/Repository/WalletRepository.cs new file mode 100644 index 0000000..fc06b8c --- /dev/null +++ b/gui/Data/Repository/WalletRepository.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reactive.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.DependencyInjection; +using Dimension.MaskCore.Common.Extension; +using Dimension.MaskCore.Data.Model; +using Dimension.MaskCore.Model; +using Dimension.MaskCore.UI.Model; +using Dimension.MaskWalletCore; +using Realms; + +namespace Dimension.MaskCore.Data.Repository; + +internal class WalletRepository +{ + private const string Path = "m/44'/60'/0'/0/0"; + private const string Password = ""; + + private static readonly Realm _realm = Ioc.Default.GetRequiredService(); + + public IObservable> Wallets { get; } = _realm.All() + .AsObservable() + .Select(list => list.Select(UiWalletModel.From).ToImmutableList()); + + public async Task CreateWallet(string name, string mnemonic, string password = Password) + { + var wallet = await Task.Run(() => WalletKey.FromMnemonic(mnemonic, password)); + var account = await Task.Run(() => wallet.AddNewAccountAtPath(CoinType.Ethereum, Path, name, password)); + if (_realm.All().Any(it => it.Address == account.Address)) + { + return; + } + + var item = new DbWalletModel + { + Name = name, + Address = account.Address, + DerivationPath = account.DerivationPath, + Data = wallet.Data, + PlatformType = PlatformType.Ethereum + }; + _realm.Write(() => _realm.Add(item)); + } + + public void RenameWallet(string address, string name) + { + var item = _realm.All().FirstOrDefault(it => it.Address == address); + if (item == null) + { + return; + } + + _realm.Write(() => item.Name = name); + } + + public void DeleteWallet(string address) + { + var item = _realm.All().FirstOrDefault(it => it.Address == address); + if (item == null) + { + return; + } + + _realm.Write(() => _realm.Remove(item)); + } +} \ No newline at end of file diff --git a/gui/FodyWeavers.xml b/gui/FodyWeavers.xml new file mode 100644 index 0000000..ef11585 --- /dev/null +++ b/gui/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/gui/Lifecycle/Controls/Dialog.cs b/gui/Lifecycle/Controls/Dialog.cs new file mode 100644 index 0000000..bbd9eb3 --- /dev/null +++ b/gui/Lifecycle/Controls/Dialog.cs @@ -0,0 +1,25 @@ +using System; +using Avalonia.LogicalTree; +using Avalonia.Styling; +using FluentAvalonia.UI.Controls; + +namespace Dimension.MaskCore.Lifecycle.Controls; + +public class Dialog : Dialog where T : ViewModel.ViewModel +{ + public T ViewModel => (DataContext as T)!; +} + +public class Dialog : ContentDialog, IStyleable +{ + Type IStyleable.StyleKey => typeof(ContentDialog); + + protected override void OnDetachedFromLogicalTree(LogicalTreeAttachmentEventArgs e) + { + base.OnDetachedFromLogicalTree(e); + if (DataContext is IDisposable disposable) + { + disposable.Dispose(); + } + } +} \ No newline at end of file diff --git a/gui/Lifecycle/Controls/Page.cs b/gui/Lifecycle/Controls/Page.cs new file mode 100644 index 0000000..3e6484a --- /dev/null +++ b/gui/Lifecycle/Controls/Page.cs @@ -0,0 +1,115 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.LogicalTree; +using Avalonia.VisualTree; +using Dimension.MaskCore.Lifecycle.ViewModel; +using FluentAvalonia.UI.Controls; +using FluentAvalonia.UI.Media.Animation; +using FluentAvalonia.UI.Navigation; + +namespace Dimension.MaskCore.Lifecycle.Controls; + +public class Page : Page + where TViewModel : ViewModel.ViewModel, new() where TParameter : class +{ + protected override void OnCreated(object parameter) + { + base.OnCreated(parameter); + if (parameter is TParameter r) + { + OnCreated(r); + } + } + + protected virtual void OnCreated(TParameter parameter) + { + if (ViewModel is IParameterizedViewModel parameterizedViewModel) + { + parameterizedViewModel.Initialize(parameter); + } + } +} + +public class Page : Page where T : ViewModel.ViewModel, new() +{ + public T ViewModel => (DataContext as T)!; +} + +public class Page : UserControl +{ + protected Frame? Frame { get; private set; } + + protected Window Window => this.FindAncestorOfType(); + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + Frame = this.FindAncestorOfType(); + Frame.Navigated += OnNavigated; + } + + private void OnNavigated(object sender, NavigationEventArgs e) + { + if (ReferenceEquals(e.Content, this)) + { + switch (e.NavigationMode) + { + case NavigationMode.New: + OnCreated(e.Parameter); + break; + case NavigationMode.Back: + break; + case NavigationMode.Forward: + break; + case NavigationMode.Refresh: + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + } + + protected virtual void OnCreated(object parameter) + { + } + + protected override void OnDetachedFromLogicalTree(LogicalTreeAttachmentEventArgs e) + { + base.OnDetachedFromLogicalTree(e); + if (DataContext is IDisposable disposable) + { + disposable.Dispose(); + } + + if (Frame != null) + { + Frame.Navigated -= OnNavigated; + Frame = null; + } + } + + protected void GoBack() + { + Frame?.GoBack(new SlideNavigationTransitionInfo + { Effect = SlideNavigationTransitionEffect.FromLeft }); + } + + protected void Navigate(object? parameter = null) + { + Frame?.Navigate(typeof(T), parameter, new SlideNavigationTransitionInfo()); + } + + protected void Navigate(Type page, object? parameter = null) + { + Frame?.NavigateToType(page, parameter, new FrameNavigationOptions + { + TransitionInfoOverride = new SlideNavigationTransitionInfo() + }); + } + + protected void ClearBackStack() + { + Frame?.BackStack?.Clear(); + } +} \ No newline at end of file diff --git a/gui/Lifecycle/ViewModel/ViewModel.cs b/gui/Lifecycle/ViewModel/ViewModel.cs new file mode 100644 index 0000000..ffb3bcf --- /dev/null +++ b/gui/Lifecycle/ViewModel/ViewModel.cs @@ -0,0 +1,30 @@ +using System; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Dimension.MaskCore.Lifecycle.ViewModel; + +[ObservableObject] +public abstract partial class ViewModel : IDisposable +{ + protected internal ViewModelScope Scope { get; } = new(); + + public void Dispose() + { + Scope.Dispose(); + } +} + +public interface IParameterizedViewModel +{ + void Initialize(T parameter); +} + +public abstract class ParameterizedViewModel : ViewModel, IParameterizedViewModel +{ + public void Initialize(T parameter) + { + InitializeCore(parameter); + } + + protected abstract void InitializeCore(T parameter); +} \ No newline at end of file diff --git a/gui/Lifecycle/ViewModel/ViewModelScope.cs b/gui/Lifecycle/ViewModel/ViewModelScope.cs new file mode 100644 index 0000000..701467e --- /dev/null +++ b/gui/Lifecycle/ViewModel/ViewModelScope.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; + +namespace Dimension.MaskCore.Lifecycle.ViewModel; + +public sealed class ViewModelScope : IDisposable +{ + private readonly List _disposables = new(); + + public void Dispose() + { + _disposables.ForEach(x => x.Dispose()); + } + + internal void Add(IDisposable disposable) + { + _disposables.Add(disposable); + } +} + +public static class RxExtensions +{ + public static void SubscribeIn(this IObservable source, ViewModel viewModel, Action onNext) + { + viewModel.Scope.Add(source.Subscribe(onNext)); + } +} \ No newline at end of file diff --git a/gui/MaskCore.csproj b/gui/MaskCore.csproj new file mode 100644 index 0000000..82b12fc --- /dev/null +++ b/gui/MaskCore.csproj @@ -0,0 +1,49 @@ + + + WinExe + net6.0 + enable + Dimension.MaskCore + Assets/favicon.ico + 0.1.0 + 0.1.0 + com.dimension + + copyused + true + + + + + + + + + + + 0.10.13 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gui/Model/PlatformType.cs b/gui/Model/PlatformType.cs new file mode 100644 index 0000000..7010b93 --- /dev/null +++ b/gui/Model/PlatformType.cs @@ -0,0 +1,6 @@ +namespace Dimension.MaskCore.Model; + +internal enum PlatformType +{ + Ethereum +} \ No newline at end of file diff --git a/gui/Program.cs b/gui/Program.cs new file mode 100644 index 0000000..560a247 --- /dev/null +++ b/gui/Program.cs @@ -0,0 +1,27 @@ +using System; +using Avalonia; +using Avalonia.ReactiveUI; + +namespace Dimension.MaskCore; + +internal class Program +{ + [STAThread] + public static void Main(string[] args) + { + BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + } + + public static AppBuilder BuildAvaloniaApp() + { + return AppBuilder.Configure() + .UsePlatformDetect() + .LogToTrace() + .UseReactiveUI() + .With(new Win32PlatformOptions + { + UseWindowsUIComposition = true + }); + } +} \ No newline at end of file diff --git a/gui/UI/Controls/MnemonicValidateControl.axaml b/gui/UI/Controls/MnemonicValidateControl.axaml new file mode 100644 index 0000000..4238ee3 --- /dev/null +++ b/gui/UI/Controls/MnemonicValidateControl.axaml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/gui/UI/Controls/MnemonicValidateControl.axaml.cs b/gui/UI/Controls/MnemonicValidateControl.axaml.cs new file mode 100644 index 0000000..af6ff42 --- /dev/null +++ b/gui/UI/Controls/MnemonicValidateControl.axaml.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Dimension.MaskCore.Common.Extension; +using Dimension.MaskCore.UI.Model; +using DynamicData; + +namespace Dimension.MaskCore.UI.Controls; + +internal partial class MnemonicValidateControl : UserControl +{ + public static readonly DirectProperty ConfirmCommandProperty = + AvaloniaProperty.RegisterDirect( + nameof(ConfirmCommand), o => o.ConfirmCommand, (o, v) => o.ConfirmCommand = v); + + public static readonly DirectProperty> MnemonicWordsProperty = + AvaloniaProperty.RegisterDirect>( + nameof(MnemonicWords), o => o.MnemonicWords, (o, v) => o.MnemonicWords = v); + + public static readonly DirectProperty IsValidProperty = + AvaloniaProperty.RegisterDirect( + nameof(IsValid), + o => o.IsValid); + + private ICommand? _confirmCommand; + private bool _isValid; + + private IReadOnlyCollection _mnemonicWords = new List(); + + public MnemonicValidateControl() + { + InitializeComponent(); + } + + public ICommand? ConfirmCommand + { + get => _confirmCommand; + set => SetAndRaise(ConfirmCommandProperty, ref _confirmCommand, value); + } + + public IReadOnlyCollection MnemonicWords + { + get => _mnemonicWords; + set + { + SetAndRaise(MnemonicWordsProperty, ref _mnemonicWords, value); + SelectedMnemonic.Clear(); + RandomMnemonic.Clear(); + RandomMnemonic.AddRange(value + .Randomize() + .Select((word, index) => new MnemonicModel(word, index))); + } + } + + public ObservableCollection SelectedMnemonic { get; set; } = new(); + public ObservableCollection RandomMnemonic { get; set; } = new(); + + public bool IsValid + { + get => _isValid; + private set => SetAndRaise(IsValidProperty, ref _isValid, value); + } + + private void CheckValid() + { + IsValid = SelectedMnemonic.Select(x => x.Word).SequenceEqual(MnemonicWords); + } + + private void ClearClicked(object? sender, RoutedEventArgs e) + { + SelectedMnemonic.Clear(); + RandomMnemonic.AddRange(MnemonicWords.Select((word, index) => new MnemonicModel(word, index))); + CheckValid(); + } + + private void AddWord(MnemonicModel item) + { + if (!RandomMnemonic.Contains(item)) + { + return; + } + + RandomMnemonic.Remove(item); + SelectedMnemonic.Add(item); + CheckValid(); + } + + private void RemoveWord(MnemonicModel item) + { + if (!SelectedMnemonic.Contains(item)) + { + return; + } + + SelectedMnemonic.Remove(item); + RandomMnemonic.Add(item); + CheckValid(); + } + + public event EventHandler? Confirm; + + private void OnConfirmClicked(object? sender, RoutedEventArgs e) + { + Confirm?.Invoke(this, EventArgs.Empty); + } +} \ No newline at end of file diff --git a/gui/UI/Model/UiPersonaModel.cs b/gui/UI/Model/UiPersonaModel.cs new file mode 100644 index 0000000..ade66d7 --- /dev/null +++ b/gui/UI/Model/UiPersonaModel.cs @@ -0,0 +1,13 @@ +using Dimension.MaskCore.Data.Model; + +namespace Dimension.MaskCore.UI.Model; + +internal record UiPersonaModel(string Name, string Identifier) +{ + public static UiPersonaModel FromDb(DbPersonaModel model) + { + return new(model.Name, model.Identifier); + } +} + +internal record MnemonicModel(string Word, int Index); \ No newline at end of file diff --git a/gui/UI/Model/UiWalletModel.cs b/gui/UI/Model/UiWalletModel.cs new file mode 100644 index 0000000..d171cd9 --- /dev/null +++ b/gui/UI/Model/UiWalletModel.cs @@ -0,0 +1,11 @@ +using Dimension.MaskCore.Data.Model; + +namespace Dimension.MaskCore.UI.Model; + +internal record UiWalletModel(string Address, string Name) +{ + public static UiWalletModel From(DbWalletModel item) + { + return new UiWalletModel(item.Address, item.Name); + } +} \ No newline at end of file diff --git a/gui/UI/Pages/Persona/CreatePersona/CreatePersonaPage.axaml b/gui/UI/Pages/Persona/CreatePersona/CreatePersonaPage.axaml new file mode 100644 index 0000000..6096094 --- /dev/null +++ b/gui/UI/Pages/Persona/CreatePersona/CreatePersonaPage.axaml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/gui/UI/Pages/Persona/MnemonicValidate/PersonaMnemonicValidatePage.axaml.cs b/gui/UI/Pages/Persona/MnemonicValidate/PersonaMnemonicValidatePage.axaml.cs new file mode 100644 index 0000000..31f4873 --- /dev/null +++ b/gui/UI/Pages/Persona/MnemonicValidate/PersonaMnemonicValidatePage.axaml.cs @@ -0,0 +1,29 @@ +using Avalonia.Interactivity; +using Dimension.MaskCore.Lifecycle.Controls; + +namespace Dimension.MaskCore.UI.Pages.Persona.MnemonicValidate; + +internal partial class + PersonaMnemonicValidatePage : Page +{ + public PersonaMnemonicValidatePage() + { + InitializeComponent(); + ViewModel.OnComplete = OnComplete; + } + + private async void OnComplete() + { + await new Dialog + { + Title = "Persona Created", + CloseButtonText = "OK" + }.ShowAsync(); + Navigate(); + } + + private void BackClicked(object? sender, RoutedEventArgs e) + { + GoBack(); + } +} \ No newline at end of file diff --git a/gui/UI/Pages/Persona/MnemonicValidate/PersonaMnemonicValidateViewModel.cs b/gui/UI/Pages/Persona/MnemonicValidate/PersonaMnemonicValidateViewModel.cs new file mode 100644 index 0000000..e5e648d --- /dev/null +++ b/gui/UI/Pages/Persona/MnemonicValidate/PersonaMnemonicValidateViewModel.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.DependencyInjection; +using CommunityToolkit.Mvvm.Input; +using Dimension.MaskCore.Data.Repository; +using Dimension.MaskCore.Lifecycle.ViewModel; + +namespace Dimension.MaskCore.UI.Pages.Persona.MnemonicValidate; + +internal partial class PersonaMnemonicValidateViewModel : ParameterizedViewModel +{ + private readonly PersonaRepository _personaRepository = Ioc.Default.GetRequiredService(); + [ObservableProperty] private IReadOnlyCollection _words = new List(); + public Action? OnComplete { get; set; } + public PersonaMnemonicValidateParameter? Parameter { get; private set; } + + protected override void InitializeCore(PersonaMnemonicValidateParameter parameter) + { + Parameter = parameter; + Words = Parameter.WordList.ToImmutableList(); + } + + [ICommand] + private async Task Confirm() + { + if (Parameter != null) + { + await _personaRepository.CreatePersona(Parameter.Name, Parameter.Mnemonic); + OnComplete?.Invoke(); + } + } +} + +internal record PersonaMnemonicValidateParameter(string Mnemonic, string Name) +{ + public string[] WordList => Mnemonic.Split(' '); +} \ No newline at end of file diff --git a/gui/UI/Pages/Persona/PersonaPage.axaml b/gui/UI/Pages/Persona/PersonaPage.axaml new file mode 100644 index 0000000..8cdc8b1 --- /dev/null +++ b/gui/UI/Pages/Persona/PersonaPage.axaml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/gui/UI/Pages/Persona/PersonaPage.axaml.cs b/gui/UI/Pages/Persona/PersonaPage.axaml.cs new file mode 100644 index 0000000..fd632db --- /dev/null +++ b/gui/UI/Pages/Persona/PersonaPage.axaml.cs @@ -0,0 +1,25 @@ +using Avalonia.Interactivity; +using Dimension.MaskCore.Lifecycle.Controls; +using Dimension.MaskCore.UI.Model; +using Dimension.MaskCore.UI.Pages.Persona.CreatePersona; +using Dimension.MaskCore.UI.Pages.Persona.RenamePersona; + +namespace Dimension.MaskCore.UI.Pages.Persona; + +internal partial class PersonaPage : Page +{ + public PersonaPage() + { + InitializeComponent(); + } + + private void New_OnClicked(object? sender, RoutedEventArgs e) + { + Navigate(); + } + + private void RenamePersona(UiPersonaModel item) + { + new RenamePersonaDialog(item).ShowAsync(); + } +} \ No newline at end of file diff --git a/gui/UI/Pages/Persona/PersonaViewModel.cs b/gui/UI/Pages/Persona/PersonaViewModel.cs new file mode 100644 index 0000000..66ba459 --- /dev/null +++ b/gui/UI/Pages/Persona/PersonaViewModel.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.DependencyInjection; +using CommunityToolkit.Mvvm.Input; +using Dimension.MaskCore.Data.Repository; +using Dimension.MaskCore.Lifecycle.ViewModel; +using Dimension.MaskCore.UI.Model; + +namespace Dimension.MaskCore.UI.Pages.Persona; + +internal partial class PersonaViewModel : ViewModel +{ + private readonly PersonaRepository _personaRepository = Ioc.Default.GetRequiredService(); + private readonly BehaviorSubject _searchTextSubject = new(string.Empty); + [ObservableProperty] private string _searchText = string.Empty; + + public PersonaViewModel() + { + Personas = _personaRepository.Personas.CombineLatest(_searchTextSubject).Throttle(TimeSpan.FromSeconds(0.3)) + .Select( + it => + { + var (personas, searchText) = it; + if (string.IsNullOrWhiteSpace(searchText)) + { + return personas; + } + + return personas.Where(persona => + persona.Name.Contains(searchText, StringComparison.OrdinalIgnoreCase) || + persona.Identifier.Contains(searchText, StringComparison.OrdinalIgnoreCase)).ToImmutableList(); + }); + } + + public IObservable> Personas { get; } + + [ICommand] + private void DeletePersona(UiPersonaModel model) + { + _personaRepository.DeletePersona(model.Identifier); + } + + partial void OnSearchTextChanged(string value) + { + _searchTextSubject.OnNext(value); + } +} \ No newline at end of file diff --git a/gui/UI/Pages/Persona/RenamePersona/RenamePersonaDialog.axaml b/gui/UI/Pages/Persona/RenamePersona/RenamePersonaDialog.axaml new file mode 100644 index 0000000..5857fca --- /dev/null +++ b/gui/UI/Pages/Persona/RenamePersona/RenamePersonaDialog.axaml @@ -0,0 +1,19 @@ + + + + + + \ No newline at end of file diff --git a/gui/UI/Pages/Persona/RenamePersona/RenamePersonaDialog.axaml.cs b/gui/UI/Pages/Persona/RenamePersona/RenamePersonaDialog.axaml.cs new file mode 100644 index 0000000..d7e7081 --- /dev/null +++ b/gui/UI/Pages/Persona/RenamePersona/RenamePersonaDialog.axaml.cs @@ -0,0 +1,18 @@ +using Dimension.MaskCore.Lifecycle.Controls; +using Dimension.MaskCore.UI.Model; + +namespace Dimension.MaskCore.UI.Pages.Persona.RenamePersona; + +internal partial class RenamePersonaDialog : Dialog +{ + public RenamePersonaDialog() + { + InitializeComponent(); + } + + public RenamePersonaDialog(UiPersonaModel item) + { + InitializeComponent(); + ViewModel.Initialize(item); + } +} \ No newline at end of file diff --git a/gui/UI/Pages/Persona/RenamePersona/RenamePersonaViewModel.cs b/gui/UI/Pages/Persona/RenamePersona/RenamePersonaViewModel.cs new file mode 100644 index 0000000..9c6ab0f --- /dev/null +++ b/gui/UI/Pages/Persona/RenamePersona/RenamePersonaViewModel.cs @@ -0,0 +1,32 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.DependencyInjection; +using CommunityToolkit.Mvvm.Input; +using Dimension.MaskCore.Data.Repository; +using Dimension.MaskCore.Lifecycle.ViewModel; +using Dimension.MaskCore.UI.Model; + +namespace Dimension.MaskCore.UI.Pages.Persona.RenamePersona; + +internal partial class RenamePersonaViewModel : ViewModel +{ + private readonly PersonaRepository _repository = Ioc.Default.GetRequiredService(); + [ObservableProperty] private string _name = string.Empty; + private UiPersonaModel? _persona; + + public void Initialize(UiPersonaModel item) + { + Name = item.Name; + _persona = item; + } + + [ICommand] + private void Rename() + { + if (_persona == null) + { + return; + } + + _repository.UpdatePersonaName(_persona.Identifier, Name); + } +} \ No newline at end of file diff --git a/gui/UI/Pages/Settings/SettingsPage.axaml b/gui/UI/Pages/Settings/SettingsPage.axaml new file mode 100644 index 0000000..74be337 --- /dev/null +++ b/gui/UI/Pages/Settings/SettingsPage.axaml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/gui/UI/Pages/Wallet/MnemonicValidate/WalletMnemonicValidatePage.axaml.cs b/gui/UI/Pages/Wallet/MnemonicValidate/WalletMnemonicValidatePage.axaml.cs new file mode 100644 index 0000000..d332f72 --- /dev/null +++ b/gui/UI/Pages/Wallet/MnemonicValidate/WalletMnemonicValidatePage.axaml.cs @@ -0,0 +1,29 @@ +using Avalonia.Interactivity; +using Dimension.MaskCore.Lifecycle.Controls; + +namespace Dimension.MaskCore.UI.Pages.Wallet.MnemonicValidate; + +internal partial class + WalletMnemonicValidatePage : Page +{ + public WalletMnemonicValidatePage() + { + InitializeComponent(); + ViewModel.OnComplete = OnComplete; + } + + private async void OnComplete() + { + await new Dialog + { + Title = "Wallet Created", + CloseButtonText = "OK" + }.ShowAsync(); + Navigate(); + } + + private void BackClicked(object? sender, RoutedEventArgs e) + { + GoBack(); + } +} \ No newline at end of file diff --git a/gui/UI/Pages/Wallet/MnemonicValidate/WalletMnemonicValidateViewModel.cs b/gui/UI/Pages/Wallet/MnemonicValidate/WalletMnemonicValidateViewModel.cs new file mode 100644 index 0000000..4968204 --- /dev/null +++ b/gui/UI/Pages/Wallet/MnemonicValidate/WalletMnemonicValidateViewModel.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.DependencyInjection; +using CommunityToolkit.Mvvm.Input; +using Dimension.MaskCore.Data.Repository; +using Dimension.MaskCore.Lifecycle.ViewModel; + +namespace Dimension.MaskCore.UI.Pages.Wallet.MnemonicValidate; + +internal partial class WalletMnemonicValidateViewModel : ParameterizedViewModel +{ + private readonly WalletRepository _repository = Ioc.Default.GetRequiredService(); + [ObservableProperty] private IReadOnlyCollection _words = new List(); + public Action? OnComplete { get; set; } + public WalletMnemonicValidateParameter? Parameter { get; private set; } + + protected override void InitializeCore(WalletMnemonicValidateParameter parameter) + { + Parameter = parameter; + Words = Parameter.WordList.ToImmutableList(); + } + + [ICommand] + private async Task Confirm() + { + if (Parameter != null) + { + await _repository.CreateWallet(Parameter.Name, Parameter.Mnemonic); + OnComplete?.Invoke(); + } + } +} + +internal record WalletMnemonicValidateParameter(string Mnemonic, string Name) +{ + public string[] WordList => Mnemonic.Split(' '); +} \ No newline at end of file diff --git a/gui/UI/Pages/Wallet/RenameWallet/RenameWalletDialog.axaml b/gui/UI/Pages/Wallet/RenameWallet/RenameWalletDialog.axaml new file mode 100644 index 0000000..7e1be54 --- /dev/null +++ b/gui/UI/Pages/Wallet/RenameWallet/RenameWalletDialog.axaml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/gui/UI/Pages/Wallet/RenameWallet/RenameWalletDialog.axaml.cs b/gui/UI/Pages/Wallet/RenameWallet/RenameWalletDialog.axaml.cs new file mode 100644 index 0000000..7382913 --- /dev/null +++ b/gui/UI/Pages/Wallet/RenameWallet/RenameWalletDialog.axaml.cs @@ -0,0 +1,18 @@ +using Dimension.MaskCore.Lifecycle.Controls; +using Dimension.MaskCore.UI.Model; + +namespace Dimension.MaskCore.UI.Pages.Wallet.RenameWallet; + +internal partial class RenameWalletDialog : Dialog +{ + public RenameWalletDialog() + { + InitializeComponent(); + } + + public RenameWalletDialog(UiWalletModel item) + { + InitializeComponent(); + ViewModel.Initialize(item); + } +} \ No newline at end of file diff --git a/gui/UI/Pages/Wallet/RenameWallet/RenameWalletViewModel.cs b/gui/UI/Pages/Wallet/RenameWallet/RenameWalletViewModel.cs new file mode 100644 index 0000000..4ecebeb --- /dev/null +++ b/gui/UI/Pages/Wallet/RenameWallet/RenameWalletViewModel.cs @@ -0,0 +1,32 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.DependencyInjection; +using CommunityToolkit.Mvvm.Input; +using Dimension.MaskCore.Data.Repository; +using Dimension.MaskCore.Lifecycle.ViewModel; +using Dimension.MaskCore.UI.Model; + +namespace Dimension.MaskCore.UI.Pages.Wallet.RenameWallet; + +internal partial class RenameWalletViewModel : ViewModel +{ + private readonly WalletRepository _repository = Ioc.Default.GetRequiredService(); + [ObservableProperty] private string _name = string.Empty; + private UiWalletModel? _wallet; + + public void Initialize(UiWalletModel item) + { + Name = item.Name; + _wallet = item; + } + + [ICommand] + private void Rename() + { + if (_wallet == null) + { + return; + } + + _repository.RenameWallet(_wallet.Address, Name); + } +} \ No newline at end of file diff --git a/gui/UI/Pages/Wallet/WalletPage.axaml b/gui/UI/Pages/Wallet/WalletPage.axaml new file mode 100644 index 0000000..4f8c75b --- /dev/null +++ b/gui/UI/Pages/Wallet/WalletPage.axaml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/gui/UI/Pages/Wallet/WalletPage.axaml.cs b/gui/UI/Pages/Wallet/WalletPage.axaml.cs new file mode 100644 index 0000000..2f88eaa --- /dev/null +++ b/gui/UI/Pages/Wallet/WalletPage.axaml.cs @@ -0,0 +1,25 @@ +using Avalonia.Interactivity; +using Dimension.MaskCore.Lifecycle.Controls; +using Dimension.MaskCore.UI.Model; +using Dimension.MaskCore.UI.Pages.Wallet.CreateWallet; +using Dimension.MaskCore.UI.Pages.Wallet.RenameWallet; + +namespace Dimension.MaskCore.UI.Pages.Wallet; + +internal partial class WalletPage : Page +{ + public WalletPage() + { + InitializeComponent(); + } + + private void New_OnClicked(object? sender, RoutedEventArgs e) + { + Navigate(); + } + + private void RenameWallet(UiWalletModel item) + { + new RenameWalletDialog(item).ShowAsync(); + } +} \ No newline at end of file diff --git a/gui/UI/Pages/Wallet/WalletViewModel.cs b/gui/UI/Pages/Wallet/WalletViewModel.cs new file mode 100644 index 0000000..65e9f64 --- /dev/null +++ b/gui/UI/Pages/Wallet/WalletViewModel.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.DependencyInjection; +using CommunityToolkit.Mvvm.Input; +using Dimension.MaskCore.Data.Repository; +using Dimension.MaskCore.Lifecycle.ViewModel; +using Dimension.MaskCore.UI.Model; +using TextCopy; + +namespace Dimension.MaskCore.UI.Pages.Wallet; + +internal partial class WalletViewModel : ViewModel +{ + private readonly WalletRepository _repository = Ioc.Default.GetRequiredService(); + private readonly BehaviorSubject _searchTextSubject = new(string.Empty); + [ObservableProperty] private string _searchText = string.Empty; + + public WalletViewModel() + { + Wallets = _repository.Wallets.CombineLatest(_searchTextSubject).Throttle(TimeSpan.FromSeconds(0.3)).Select( + it => + { + var (wallets, searchText) = it; + if (string.IsNullOrWhiteSpace(searchText)) + { + return wallets; + } + + return wallets.Where(wallet => + wallet.Name.Contains(searchText, StringComparison.OrdinalIgnoreCase) || + wallet.Address.Contains(searchText, StringComparison.OrdinalIgnoreCase)).ToImmutableList(); + }); + } + + public IObservable> Wallets { get; } + + [ICommand] + private void DeleteWallet(UiWalletModel model) + { + _repository.DeleteWallet(model.Address); + } + + [ICommand] + private void CopyAddress(UiWalletModel model) + { + ClipboardService.SetText(model.Address); + } + + partial void OnSearchTextChanged(string value) + { + _searchTextSubject.OnNext(value); + } +} \ No newline at end of file diff --git a/gui/UI/Shell/RootShell.axaml b/gui/UI/Shell/RootShell.axaml new file mode 100644 index 0000000..1ea0abb --- /dev/null +++ b/gui/UI/Shell/RootShell.axaml @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/gui/UI/Shell/RootShell.axaml.cs b/gui/UI/Shell/RootShell.axaml.cs new file mode 100644 index 0000000..7be303b --- /dev/null +++ b/gui/UI/Shell/RootShell.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace Dimension.MaskCore.UI.Shell; + +internal partial class RootShell : UserControl +{ + public RootShell() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/gui/UI/Shell/RootWindow.axaml b/gui/UI/Shell/RootWindow.axaml new file mode 100644 index 0000000..abd574d --- /dev/null +++ b/gui/UI/Shell/RootWindow.axaml @@ -0,0 +1,14 @@ + + + \ No newline at end of file diff --git a/gui/UI/Shell/RootWindow.axaml.cs b/gui/UI/Shell/RootWindow.axaml.cs new file mode 100644 index 0000000..c161f45 --- /dev/null +++ b/gui/UI/Shell/RootWindow.axaml.cs @@ -0,0 +1,15 @@ +using Avalonia; +using FluentAvalonia.UI.Controls; + +namespace Dimension.MaskCore.UI.Shell; + +internal partial class RootWindow : CoreWindow +{ + public RootWindow() + { + InitializeComponent(); +#if DEBUG + this.AttachDevTools(); +#endif + } +} \ No newline at end of file