Commit b5ae85c1 authored by Roman Alifanov's avatar Roman Alifanov

initial commit

parents
MIT License
Copyright (c) 2025 Etersoft
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
module SystemUpdater
go 1.25.0
require (
github.com/diamondburned/gotk4-adwaita/pkg v0.0.0-20250703085337-e94555b846b6
github.com/diamondburned/gotk4/pkg v0.3.2-0.20250703063411-16654385f59a
github.com/godbus/dbus/v5 v5.1.0
)
require (
github.com/KarpelesLab/weak v0.1.1 // indirect
go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
)
github.com/KarpelesLab/weak v0.1.1 h1:fNnlPo3aypS9tBzoEQluY13XyUfd/eWaSE/vMvo9s4g=
github.com/KarpelesLab/weak v0.1.1/go.mod h1:pzXsWs5f2bf+fpgHayTlBE1qJpO3MpJKo5sRaLu1XNw=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/diamondburned/gotk4-adwaita/pkg v0.0.0-20250703085337-e94555b846b6 h1:WzOC3KtvrC1hJMz3fbJBg0Ye50nt4Tafor+a/bBHNEA=
github.com/diamondburned/gotk4-adwaita/pkg v0.0.0-20250703085337-e94555b846b6/go.mod h1:ZzYiyPe0TqsukfPHi0sK/WwKzm0wIJdSRylLnuvAZNw=
github.com/diamondburned/gotk4/pkg v0.3.2-0.20250703063411-16654385f59a h1:dN2jYYZ71hFhoKFSn24pQdKWLZb/XDydBt8pEIkFjJo=
github.com/diamondburned/gotk4/pkg v0.3.2-0.20250703063411-16654385f59a/go.mod h1:O9K8+PGNFGJpAu8+u5D2Sn5Wae4hxWzHB+AeZNbV/2Q=
github.com/esiqveland/notify v0.13.3 h1:QCMw6o1n+6rl+oLUfg8P1IIDSFsDEb2WlXvVvIJbI/o=
github.com/esiqveland/notify v0.13.3/go.mod h1:hesw/IRYTO0x99u1JPweAl4+5mwXJibQVUcP0Iu5ORE=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 h1:lGdhQUN/cnWdSH3291CUuxSEqc+AsGTiDxPP3r2J0l4=
go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
package apm
import (
"encoding/xml"
)
type LocalizedText struct {
Lang string `xml:"lang,attr,omitempty" json:"lang,omitempty"`
Value string `xml:",innerxml" json:"value"`
}
type URL struct {
Type string `xml:"type,attr,omitempty" json:"type,omitempty"`
Value string `xml:",chardata" json:"value"`
}
type Keyword struct {
Value string `xml:",chardata" json:"value"`
}
type ScreenshotImage struct {
Type string `xml:"type,attr,omitempty" json:"type,omitempty"`
Width int `xml:"width,attr,omitempty" json:"width,omitempty"`
Height int `xml:"height,attr,omitempty" json:"height,omitempty"`
URL string `xml:",chardata" json:"url"`
}
type Screenshot struct {
Type string `xml:"type,attr,omitempty" json:"type,omitempty"`
Caption []LocalizedText `xml:"caption" json:"caption,omitempty"`
Images []ScreenshotImage `xml:"image" json:"images,omitempty"`
}
type Release struct {
Timestamp int64 `xml:"timestamp,attr" json:"timestamp"`
Version string `xml:"version,attr" json:"version"`
}
type Launchable struct {
Type string `xml:"type,attr" json:"type,omitempty"`
Value string `xml:",chardata" json:"value"`
}
type ContentRating struct {
Type string `xml:"type,attr,omitempty" json:"type,omitempty"`
Content string `xml:",innerxml" json:"content"`
}
type Icon struct {
Type string `xml:"type,attr" json:"type"`
Width int `xml:"width,attr,omitempty" json:"width,omitempty"`
Height int `xml:"height,attr,omitempty" json:"height,omitempty"`
Value string `xml:",chardata" json:"value"`
}
type Component struct {
XMLName xml.Name `xml:"component" json:"-"`
Type string `xml:"type,attr" json:"type"`
ID string `xml:"id" json:"id,omitempty"`
MetadataLicense string `xml:"metadata_license" json:"metadata_license,omitempty"`
ProjectLicense string `xml:"project_license,omitempty" json:"project_license,omitempty"`
Name []LocalizedText `xml:"name" json:"name,omitempty"`
Summary []LocalizedText `xml:"summary" json:"summary,omitempty"`
Description []LocalizedText `xml:"description" json:"description,omitempty"`
Keywords []Keyword `xml:"keywords>keyword" json:"keywords,omitempty"`
Categories []string `xml:"categories>category" json:"categories,omitempty"`
Urls []URL `xml:"url" json:"urls,omitempty"`
Screenshots []Screenshot `xml:"screenshots>screenshot" json:"screenshots,omitempty"`
Releases []Release `xml:"releases>release" json:"releases,omitempty"`
Icons []Icon `xml:"icon" json:"icons,omitempty"`
Launchable *Launchable `xml:"launchable,omitempty" json:"launchable,omitempty"`
ContentRating *ContentRating `xml:"content_rating,omitempty" json:"content_rating,omitempty"`
PkgName string `xml:"pkgname" json:"pkgname"`
}
type Package struct {
Name string `json:"name"`
Architecture string `json:"architecture"`
Section string `json:"section"`
InstalledSize int `json:"installedSize"`
Maintainer string `json:"maintainer"`
Version string `json:"version"`
VersionRaw string `json:"versionRaw"`
VersionInstalled string `json:"versionInstalled"`
Depends []string `json:"depends"`
Aliases []string `json:"aliases"`
Provides []string `json:"provides"`
Size int `json:"size"`
Filename string `json:"filename"`
Description string `json:"description"`
AppStream *Component `json:"appStream"`
Changelog string `json:"lastChangelog"`
Installed bool `json:"installed"`
TypePackage int `json:"typePackage"`
}
type InfoResponse struct {
Message string `json:"message"`
PackageInfo Package `json:"packageInfo"`
}
package apm
import (
"encoding/json"
"github.com/godbus/dbus/v5"
)
type UpdaterSource interface {
GetPackageChanges() PackageChanges
}
type PackageChanges struct {
ExtraInstalled []string `json:"extraInstalled"`
UpgradedPackages []string `json:"upgradedPackages"`
NewInstalledPackages []string `json:"newInstalledPackages"`
RemovedPackages []string `json:"removedPackages"`
UpgradedCount int `json:"upgradedCount"`
NewInstalledCount int `json:"newInstalledCount"`
RemovedCount int `json:"removedCount"`
NotUpgradedCount int `json:"-"`
DownloadSize uint64 `json:"downloadSize"`
InstallSize uint64 `json:"installSize"`
}
type Response struct {
Data struct {
Info PackageChanges `json:"info"`
Message string `json:"message"`
} `json:"data"`
Error bool `json:"error"`
}
func updatesOutputProcessing(reply *dbus.Call) PackageChanges {
if reply.Err != nil {
panic("Error during async method call: " + reply.Err.Error())
}
if len(reply.Body) < 1 {
panic("Unexpected reply body")
}
responseStr, ok := reply.Body[0].(string)
if !ok {
panic("Unexpected response type")
}
var response Response
if err := json.Unmarshal([]byte(responseStr), &response); err != nil {
panic("Failed to parse response: " + err.Error())
}
return response.Data.Info
}
type UpdatesSources map[string]UpdaterSource
func NewUpdatesSources(conn *dbus.Conn, obj dbus.BusObject) UpdatesSources {
return UpdatesSources{
"System": &SystemUpdatesSource{conn: conn, obj: obj},
// "Kernel": &KernelUpdatesSource{conn: conn, obj: obj},
}
}
type SystemUpdatesSource struct {
conn *dbus.Conn
obj dbus.BusObject
}
func (s *SystemUpdatesSource) GetPackageChanges() PackageChanges {
reply := s.obj.Call("org.altlinux.APM.system.CheckUpgrade", 0, "Ximper System Updater")
return updatesOutputProcessing(reply)
}
type KernelUpdatesSource struct {
conn *dbus.Conn
obj dbus.BusObject
}
func (k *KernelUpdatesSource) GetPackageChanges() PackageChanges {
reply := k.obj.Call("org.altlinux.APM.kernel.CheckUpdateKernel", 0, "Ximper System Updater")
return updatesOutputProcessing(reply)
}
package gtksbuilder
import (
"github.com/diamondburned/gotk4/pkg/gtk/v4"
)
var translationDomain = "somedomain"
func SetTranslationDomain(domain string) {
translationDomain = domain
}
func New(uiXML string) *gtk.Builder {
builder := gtk.NewBuilderFromString(uiXML)
builder.SetTranslationDomain(translationDomain)
return builder
}
func GetObject[T any](builder *gtk.Builder, name string) T {
return builder.GetObject(name).Cast().(T)
}
func GetObjects[T any](builder *gtk.Builder, names ...string) map[string]T {
objects := make(map[string]T)
for _, name := range names {
objects[name] = GetObject[T](builder, name)
}
return objects
}
<?xml version='1.0' encoding='UTF-8'?>
<!-- Created with Cambalache 0.94.1 -->
<interface domain="ximper-system-updater">
<requires lib="gtk" version="4.12"/>
<requires lib="libadwaita" version="1.6"/>
<object class="AdwNavigationPage" id="listpage">
<child>
<object class="AdwToolbarView">
<property name="top-bar-style">raised</property>
<child type="top">
<object class="AdwHeaderBar" id="header_bar">
<child type="end">
<object class="GtkButton">
<property name="action-name">app.about</property>
<property name="icon-name">help-about-symbolic</property>
</object>
</child>
</object>
</child>
<child>
<object class="AdwToolbarView">
<property name="content">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="GtkSearchBar" id="search_bar">
<property name="search-mode-enabled"/>
<child>
<object class="AdwClamp">
<property name="maximum-size">480</property>
<child>
<object class="GtkBox">
<child>
<object class="GtkSearchEntry" id="search_entry">
<property name="hexpand">true</property>
</object>
</child>
<child>
<object class="GtkDropDown" id="search_dropdown">
<property name="halign">end</property>
<property name="model">
<object class="GtkStringList">
<items>
<item translatable="yes">All</item>
<item translatable="yes">Installed</item>
<item translatable="yes">Uninstalled</item>
<item translatable="yes">Changed</item>
</items>
</object>
</property>
</object>
</child>
<style>
<class name="linked"/>
</style>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="GtkScrolledWindow">
<property name="hscrollbar-policy">never</property>
<property name="propagate-natural-height">true</property>
<child>
<object class="AdwClamp">
<property name="margin-bottom">12</property>
<property name="margin-end">12</property>
<property name="margin-start">12</property>
<property name="margin-top">12</property>
<child>
<object class="GtkListBox" id="updates_listbox">
<property name="selection-mode">none</property>
<style>
<class name="boxed-list-separate"/>
</style>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</property>
<child type="bottom">
<object class="GtkCenterBox">
<property name="center-widget">
<object class="AdwClamp">
<property name="margin-end">12</property>
<property name="margin-start">12</property>
<property name="maximum-size">500</property>
<child>
<object class="GtkListBox">
<child>
<object class="AdwButtonRow" id="apply_button">
<property name="title" translatable="yes">Update</property>
<style>
<class name="suggested-action"/>
</style>
</object>
</child>
<style>
<class name="boxed-list"/>
</style>
</object>
</child>
</object>
</property>
<property name="halign">center</property>
<property name="margin-bottom">12</property>
<property name="margin-end">12</property>
<property name="margin-start">12</property>
<property name="margin-top">12</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</interface>
package main
import (
"SystemUpdater/lib/apm"
bldr "SystemUpdater/lib/gtks/builder"
"log"
"os"
"unsafe"
_ "embed"
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
"github.com/diamondburned/gotk4/pkg/gio/v2"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
"github.com/godbus/dbus/v5"
)
type SystemUpdater struct {
XDGName string
App *adw.Application
AppGTK *gtk.Application
}
var mainApp *SystemUpdater
func GetSystemUpdater() *SystemUpdater {
if mainApp == nil {
xdgName := "ru.ximperlinux.SystemUpdater"
appAdw := adw.NewApplication(xdgName, gio.ApplicationFlagsNone)
appGtk := (*gtk.Application)(unsafe.Pointer(appAdw))
mainApp = &SystemUpdater{
XDGName: xdgName,
App: appAdw,
AppGTK: appGtk,
}
}
return mainApp
}
func (su *SystemUpdater) onActivate() {
conn, err := dbus.ConnectSystemBus()
if err != nil {
log.Fatal(err)
}
defer conn.Close()
obj := conn.Object("org.altlinux.APM", dbus.ObjectPath("/org/altlinux/APM"))
upds := apm.NewUpdatesSources(conn, obj)
window := GetSystemUpdaterWindow(su.AppGTK)
for _, us := range upds {
window.FillWithChanges(su, us)
}
window.Present()
}
//go:embed window.ui
var SystemUpdaterWindowUIXML string
type SystemUpdaterWindow struct {
win *adw.ApplicationWindow
navView *adw.NavigationView
listbox *gtk.ListBox
}
func (sw *SystemUpdaterWindow) FillWithChanges(su *SystemUpdater, us apm.UpdaterSource) {
sw.listbox.Append(NewUpdateRow("System packages", us.GetPackageChanges()))
}
var mainWin *SystemUpdaterWindow
func GetSystemUpdaterWindow(app *gtk.Application) *SystemUpdaterWindow {
if mainWin == nil {
builder := bldr.New(SystemUpdaterWindowUIXML)
win := bldr.GetObject[*adw.ApplicationWindow](builder, "main_window")
win.SetApplication(app)
navView := bldr.GetObject[*adw.NavigationView](builder, "navigationv")
listbox := bldr.GetObject[*gtk.ListBox](builder, "updates_listbox")
mainWin = &SystemUpdaterWindow{
win: win,
navView: navView,
listbox: listbox,
}
}
return mainWin
}
func (sw *SystemUpdaterWindow) Present() {
sw.win.Present()
}
func SystemUpdaterApplication(su *SystemUpdater) {
su.App.ConnectActivate(func() {
su.onActivate()
})
os.Exit(su.App.Run(os.Args))
}
func main() {
SystemUpdaterApplication(GetSystemUpdater())
}
package main
import (
"SystemUpdater/lib/apm"
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
)
func NewPackageRow(item string, ver string) *adw.ActionRow {
row := adw.NewActionRow()
row.SetTitle(item)
row.SetSubtitle(ver)
return row
}
func NewUpdateRow(name string, info apm.PackageChanges) *adw.ExpanderRow {
exrow := adw.NewExpanderRow()
exrow.SetTitle(name)
for title, pkgs := range map[string][]string{
"Upgraded Packages": info.UpgradedPackages,
"New Installed Packages": info.NewInstalledPackages,
"Removed Packages": info.RemovedPackages,
} {
if len(pkgs) == 0 {
continue
}
row := adw.NewExpanderRow()
row.SetTitle(title)
for _, p := range pkgs {
ar := adw.NewActionRow()
ar.SetTitle(p)
row.AddRow(ar)
}
exrow.AddRow(row)
}
return exrow
}
This diff is collapsed. Click to expand it.
<?xml version='1.0' encoding='UTF-8'?>
<!-- Created with Cambalache 0.94.1 -->
<interface domain="ximper-system-updater">
<!-- interface-name window.ui -->
<requires lib="gio" version="2.0"/>
<requires lib="gtk" version="4.0"/>
<requires lib="libadwaita" version="1.6"/>
<object class="AdwApplicationWindow" id="main_window">
<property name="default-height">489</property>
<property name="default-width">300</property>
<property name="height-request">294</property>
<property name="width-request">360</property>
<child>
<object class="AdwNavigationView" id="navigationv">
<child>
<object class="AdwNavigationPage">
<child>
<object class="AdwToolbarView">
<property name="content">
<object class="GtkStack" id="main_stack">
<property name="transition-type">crossfade</property>
<child>
<object class="GtkStackPage">
<property name="child">
<object class="AdwToolbarView">
<property name="content">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="GtkSearchBar" id="search_bar">
<property name="key-capture-widget">main_window</property>
<property name="search-mode-enabled"/>
<child>
<object class="AdwClamp">
<property name="maximum-size">480</property>
<child>
<object class="GtkBox">
<child>
<object class="GtkSearchEntry" id="search_entry">
<property name="hexpand">true</property>
</object>
</child>
<child>
<object class="GtkDropDown" id="search_dropdown">
<property name="halign">end</property>
<property name="model">
<object class="GtkStringList">
<items>
<item translatable="yes">All</item>
<item translatable="yes">Installed</item>
<item translatable="yes">Uninstalled</item>
<item translatable="yes">Changed</item>
</items>
</object>
</property>
</object>
</child>
<style>
<class name="linked"/>
</style>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="GtkScrolledWindow">
<property name="hscrollbar-policy">never</property>
<property name="propagate-natural-height">true</property>
<child>
<object class="AdwClamp">
<property name="margin-bottom">12</property>
<property name="margin-end">12</property>
<property name="margin-start">12</property>
<property name="margin-top">12</property>
<child>
<object class="GtkListBox" id="updates_listbox">
<property name="selection-mode">none</property>
<style>
<class name="boxed-list-separate"/>
</style>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</property>
<child type="bottom">
<object class="GtkCenterBox">
<property name="center-widget">
<object class="AdwClamp">
<property name="margin-end">12</property>
<property name="margin-start">12</property>
<property name="maximum-size">500</property>
<child>
<object class="GtkListBox">
<child>
<object class="AdwButtonRow" id="apply_button">
<property name="title" translatable="yes">Update</property>
<style>
<class name="suggested-action"/>
</style>
</object>
</child>
<style>
<class name="boxed-list"/>
</style>
</object>
</child>
</object>
</property>
<property name="halign">center</property>
<property name="margin-bottom">12</property>
<property name="margin-end">12</property>
<property name="margin-start">12</property>
<property name="margin-top">12</property>
</object>
</child>
</object>
</property>
<property name="name">main</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="child">
<object class="AdwStatusPage" id="status_page">
<property name="child">
<object class="AdwSpinner">
<property name="halign">center</property>
<property name="height-request">80</property>
<property name="valign">start</property>
<property name="width-request">80</property>
</object>
</property>
<property name="title" translatable="yes">Loading…</property>
</object>
</property>
<property name="name">loading</property>
</object>
</child>
</object>
</property>
<property name="top-bar-style">raised</property>
<child type="top">
<object class="AdwHeaderBar" id="header_bar">
<child type="end">
<object class="GtkButton">
<property name="action-name">app.about</property>
<property name="icon-name">help-about-symbolic</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
<menu id="primary_menu">
<section>
<item>
<attribute name="action">app.about</attribute>
<attribute name="label" translatable="yes">_About Eepm-play-gui</attribute>
</item>
</section>
</menu>
</interface>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment