From 8d9e72b66d4706b14785a08e081b255b7be40f7c Mon Sep 17 00:00:00 2001 From: Nils Abdi Andersen-Ale <6367969+nilsabdi@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:17:20 +0200 Subject: [PATCH 1/5] feat(config): add the BLAST.tv profile url --- app/config/storage.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/config/storage.go b/app/config/storage.go index 7282b19..8d54a16 100644 --- a/app/config/storage.go +++ b/app/config/storage.go @@ -19,6 +19,10 @@ const ( FILE_SYSTEM_NAME = "FileSystem" ) +// Upload tokens are created and revoked from the BLAST.tv profile page, so the storage +// settings link to it rather than explaining where to find one. +const BLAST_PROFILE_URL = "https://blast.tv/profile" + type storageListConfig []*StorageConfig func (wls *storageListConfig) UnmarshalJSON(data []byte) error { From f14f9ec2c9f453f382dfc95e0e85f3c536be11d4 Mon Sep 17 00:00:00 2001 From: Nils Abdi Andersen-Ale <6367969+nilsabdi@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:17:20 +0200 Subject: [PATCH 2/5] feat(ui): link to the BLAST.tv profile from the storage settings --- app/ui/storage_settings.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/app/ui/storage_settings.go b/app/ui/storage_settings.go index 01bbab9..b30e99b 100644 --- a/app/ui/storage_settings.go +++ b/app/ui/storage_settings.go @@ -2,6 +2,7 @@ package ui import ( "fmt" + "net/url" "strings" "github.com/LEX0RE/rockpload/app/config" @@ -29,12 +30,14 @@ type StorageInfoContainer struct { templateNameEntry *widget.Entry sendLiveCheck *widget.Check livePathEntry *widget.Entry + profileLink *widget.Hyperlink urlForm *widget.FormItem storageTypeForm *widget.FormItem uriParamsForm *widget.FormItem needTokenForm *widget.FormItem tokenForm *widget.FormItem + profileLinkForm *widget.FormItem sendPingForm *widget.FormItem pingPathForm *widget.FormItem sendReplayForm *widget.FormItem @@ -169,6 +172,15 @@ func (wsp *StorageSettingsPopup) createInfoContainer() *widget.Form { wsp.infoContainer.tokenEntry = widget.NewPasswordEntry() wsp.infoContainer.tokenEntry.SetPlaceHolder("") + // Only shown for the BLAST.tv storage: its token is generated on the profile page, + // so the settings send the user straight there instead of describing the way. + wsp.infoContainer.profileLink = widget.NewHyperlink("Create one on blast.tv/profile", nil) + if profileURL, err := url.Parse(config.BLAST_PROFILE_URL); err == nil { + wsp.infoContainer.profileLink.SetURL(profileURL) + } else { + logger.Rlogger.Debug("Failed to parse the BLAST.tv profile url", "err", err) + } + wsp.infoContainer.sendPingCheck = widget.NewCheck("", func(v bool) { wsp.currentWebsite.SendPing = v wsp.reload() @@ -194,6 +206,7 @@ func (wsp *StorageSettingsPopup) createInfoContainer() *widget.Form { wsp.infoContainer.uriParamsForm = widget.NewFormItem("URI Params", wsp.infoContainer.uriParamsEntry) wsp.infoContainer.needTokenForm = widget.NewFormItem("Need Token", wsp.infoContainer.needTokenCheck) wsp.infoContainer.tokenForm = widget.NewFormItem("Token", wsp.infoContainer.tokenEntry) + wsp.infoContainer.profileLinkForm = widget.NewFormItem("Need a token?", wsp.infoContainer.profileLink) wsp.infoContainer.sendPingForm = widget.NewFormItem("Send Ping", wsp.infoContainer.sendPingCheck) wsp.infoContainer.pingPathForm = widget.NewFormItem("Ping Path", wsp.infoContainer.pingPathEntry) wsp.infoContainer.sendReplayForm = widget.NewFormItem("Send Replay", wsp.infoContainer.sendReplayCheck) @@ -210,6 +223,7 @@ func (wsp *StorageSettingsPopup) createInfoContainer() *widget.Form { wsp.infoContainer.storageTypeForm, wsp.infoContainer.needTokenForm, wsp.infoContainer.tokenForm, + wsp.infoContainer.profileLinkForm, wsp.infoContainer.sendPingForm, wsp.infoContainer.pingPathForm, wsp.infoContainer.uriParamsForm, @@ -284,6 +298,8 @@ func (wsp *StorageSettingsPopup) reloadShow() { wsp.infoContainer.uriParamsForm.Widget.Show() wsp.infoContainer.needTokenForm.Widget.Show() wsp.infoContainer.tokenForm.Widget.Show() + // Shown again below for the BLAST.tv storage, the only one handing out its own tokens. + wsp.infoContainer.profileLinkForm.Widget.Hide() wsp.infoContainer.sendPingForm.Widget.Show() wsp.infoContainer.pingPathForm.Widget.Show() wsp.infoContainer.sendReplayForm.Widget.Show() @@ -332,6 +348,10 @@ func (wsp *StorageSettingsPopup) reloadShow() { wsp.infoContainer.pingPathForm.Widget.Hide() wsp.infoContainer.sendLiveForm.Widget.Hide() wsp.infoContainer.livePathForm.Widget.Hide() + + if wsp.infoContainer.needTokenCheck.Checked { + wsp.infoContainer.profileLinkForm.Widget.Show() + } default: fallthrough case config.WebsiteConfig: From 3338a680bc44b69cd0a0a8aef514be27eb89770a Mon Sep 17 00:00:00 2001 From: Nils Abdi Andersen-Ale <6367969+nilsabdi@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:52:43 +0200 Subject: [PATCH 3/5] fix(ui): only show the BLAST.tv profile link on the BLAST.tv storage --- app/ui/storage_settings.go | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/app/ui/storage_settings.go b/app/ui/storage_settings.go index b30e99b..a7724c9 100644 --- a/app/ui/storage_settings.go +++ b/app/ui/storage_settings.go @@ -30,14 +30,13 @@ type StorageInfoContainer struct { templateNameEntry *widget.Entry sendLiveCheck *widget.Check livePathEntry *widget.Entry - profileLink *widget.Hyperlink + profileLinkBox *fyne.Container urlForm *widget.FormItem storageTypeForm *widget.FormItem uriParamsForm *widget.FormItem needTokenForm *widget.FormItem tokenForm *widget.FormItem - profileLinkForm *widget.FormItem sendPingForm *widget.FormItem pingPathForm *widget.FormItem sendReplayForm *widget.FormItem @@ -76,7 +75,9 @@ func NewStorageSettingsPopup(p *Popup) *StorageSettingsPopup { wsp.btnDelete.Disable() wsp.editForm = wsp.createInfoContainer() - scrollableForm := container.NewVScroll(wsp.editForm) + // A hidden child takes no room in a VBox, so the link leaves no trace on the storages + // that don't hand out their own tokens. + scrollableForm := container.NewVScroll(container.NewVBox(wsp.editForm, wsp.infoContainer.profileLinkBox)) buttonsBox := container.NewHBox(layout.NewSpacer(), wsp.btnSave, wsp.btnDelete) wsp.detailPanel = container.NewBorder( @@ -173,14 +174,19 @@ func (wsp *StorageSettingsPopup) createInfoContainer() *widget.Form { wsp.infoContainer.tokenEntry.SetPlaceHolder("") // Only shown for the BLAST.tv storage: its token is generated on the profile page, - // so the settings send the user straight there instead of describing the way. - wsp.infoContainer.profileLink = widget.NewHyperlink("Create one on blast.tv/profile", nil) + // so the settings send the user straight there instead of describing the way. It lives + // outside the form because a form row only collapses once its label is hidden too, + // which a FormItem gives no handle on: hiding the widget alone strands the label. + profileLink := widget.NewHyperlink("Create one on blast.tv/profile", nil) if profileURL, err := url.Parse(config.BLAST_PROFILE_URL); err == nil { - wsp.infoContainer.profileLink.SetURL(profileURL) + profileLink.SetURL(profileURL) } else { logger.Rlogger.Debug("Failed to parse the BLAST.tv profile url", "err", err) } + wsp.infoContainer.profileLinkBox = container.NewHBox(widget.NewLabel("Need a token?"), profileLink) + wsp.infoContainer.profileLinkBox.Hide() + wsp.infoContainer.sendPingCheck = widget.NewCheck("", func(v bool) { wsp.currentWebsite.SendPing = v wsp.reload() @@ -206,7 +212,6 @@ func (wsp *StorageSettingsPopup) createInfoContainer() *widget.Form { wsp.infoContainer.uriParamsForm = widget.NewFormItem("URI Params", wsp.infoContainer.uriParamsEntry) wsp.infoContainer.needTokenForm = widget.NewFormItem("Need Token", wsp.infoContainer.needTokenCheck) wsp.infoContainer.tokenForm = widget.NewFormItem("Token", wsp.infoContainer.tokenEntry) - wsp.infoContainer.profileLinkForm = widget.NewFormItem("Need a token?", wsp.infoContainer.profileLink) wsp.infoContainer.sendPingForm = widget.NewFormItem("Send Ping", wsp.infoContainer.sendPingCheck) wsp.infoContainer.pingPathForm = widget.NewFormItem("Ping Path", wsp.infoContainer.pingPathEntry) wsp.infoContainer.sendReplayForm = widget.NewFormItem("Send Replay", wsp.infoContainer.sendReplayCheck) @@ -223,7 +228,6 @@ func (wsp *StorageSettingsPopup) createInfoContainer() *widget.Form { wsp.infoContainer.storageTypeForm, wsp.infoContainer.needTokenForm, wsp.infoContainer.tokenForm, - wsp.infoContainer.profileLinkForm, wsp.infoContainer.sendPingForm, wsp.infoContainer.pingPathForm, wsp.infoContainer.uriParamsForm, @@ -299,7 +303,7 @@ func (wsp *StorageSettingsPopup) reloadShow() { wsp.infoContainer.needTokenForm.Widget.Show() wsp.infoContainer.tokenForm.Widget.Show() // Shown again below for the BLAST.tv storage, the only one handing out its own tokens. - wsp.infoContainer.profileLinkForm.Widget.Hide() + wsp.infoContainer.profileLinkBox.Hide() wsp.infoContainer.sendPingForm.Widget.Show() wsp.infoContainer.pingPathForm.Widget.Show() wsp.infoContainer.sendReplayForm.Widget.Show() @@ -350,7 +354,7 @@ func (wsp *StorageSettingsPopup) reloadShow() { wsp.infoContainer.livePathForm.Widget.Hide() if wsp.infoContainer.needTokenCheck.Checked { - wsp.infoContainer.profileLinkForm.Widget.Show() + wsp.infoContainer.profileLinkBox.Show() } default: fallthrough From 753468c0248df750e4ff79d05108d83c87f3377f Mon Sep 17 00:00:00 2001 From: Nils Abdi Andersen-Ale <6367969+nilsabdi@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:11:10 +0200 Subject: [PATCH 4/5] fix(ui): move the BLAST.tv profile link above the storage form --- app/ui/storage_settings.go | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/app/ui/storage_settings.go b/app/ui/storage_settings.go index a7724c9..7f4e577 100644 --- a/app/ui/storage_settings.go +++ b/app/ui/storage_settings.go @@ -75,13 +75,15 @@ func NewStorageSettingsPopup(p *Popup) *StorageSettingsPopup { wsp.btnDelete.Disable() wsp.editForm = wsp.createInfoContainer() - // A hidden child takes no room in a VBox, so the link leaves no trace on the storages - // that don't hand out their own tokens. - scrollableForm := container.NewVScroll(container.NewVBox(wsp.editForm, wsp.infoContainer.profileLinkBox)) + scrollableForm := container.NewVScroll(wsp.editForm) buttonsBox := container.NewHBox(layout.NewSpacer(), wsp.btnSave, wsp.btnDelete) wsp.detailPanel = container.NewBorder( - container.NewVBox(wsp.infoContainer.titleLabel, widget.NewSeparator()), + // The token link belongs to the header instead of the form: the form arranges its rows + // in a two column grid, so anything added around it lines up with neither the labels + // nor the inputs. A hidden child takes no room in a VBox, so it leaves no trace on the + // storages that don't hand out their own tokens. + container.NewVBox(wsp.infoContainer.titleLabel, wsp.infoContainer.profileLinkBox, widget.NewSeparator()), buttonsBox, nil, nil, scrollableForm, @@ -173,10 +175,10 @@ func (wsp *StorageSettingsPopup) createInfoContainer() *widget.Form { wsp.infoContainer.tokenEntry = widget.NewPasswordEntry() wsp.infoContainer.tokenEntry.SetPlaceHolder("") - // Only shown for the BLAST.tv storage: its token is generated on the profile page, - // so the settings send the user straight there instead of describing the way. It lives - // outside the form because a form row only collapses once its label is hidden too, - // which a FormItem gives no handle on: hiding the widget alone strands the label. + // Only shown for the BLAST.tv storage: its token is generated on the profile page, so the + // settings send the user straight there instead of describing the way. It is centered under + // the title it belongs to, and kept out of the form because a form row only collapses once + // its label is hidden too, which a FormItem gives no handle on. profileLink := widget.NewHyperlink("Create one on blast.tv/profile", nil) if profileURL, err := url.Parse(config.BLAST_PROFILE_URL); err == nil { profileLink.SetURL(profileURL) @@ -184,7 +186,9 @@ func (wsp *StorageSettingsPopup) createInfoContainer() *widget.Form { logger.Rlogger.Debug("Failed to parse the BLAST.tv profile url", "err", err) } - wsp.infoContainer.profileLinkBox = container.NewHBox(widget.NewLabel("Need a token?"), profileLink) + wsp.infoContainer.profileLinkBox = container.NewCenter( + container.NewHBox(widget.NewLabel("Need a token?"), profileLink), + ) wsp.infoContainer.profileLinkBox.Hide() wsp.infoContainer.sendPingCheck = widget.NewCheck("", func(v bool) { @@ -292,6 +296,9 @@ func (wsp *StorageSettingsPopup) reload() { wsp.reloadEnable() wsp.editForm.Refresh() + // Showing a container only clears its hidden flag, so the header has to be laid out again + // for the token link to take (or give back) its room. + wsp.detailPanel.Refresh() } func (wsp *StorageSettingsPopup) reloadShow() { From 81a211b59734d3aefc2b03aba1c60dc1c4432651 Mon Sep 17 00:00:00 2001 From: Nils Abdi Andersen-Ale <6367969+nilsabdi@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:24:10 +0200 Subject: [PATCH 5/5] feat(config): tag the BLAST.tv profile link with campaign parameters --- app/config/storage.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/config/storage.go b/app/config/storage.go index 8d54a16..3d3ecff 100644 --- a/app/config/storage.go +++ b/app/config/storage.go @@ -20,8 +20,10 @@ const ( ) // Upload tokens are created and revoked from the BLAST.tv profile page, so the storage -// settings link to it rather than explaining where to find one. -const BLAST_PROFILE_URL = "https://blast.tv/profile" +// settings link to it rather than explaining where to find one. Opening a link from a desktop +// app sends no referrer, so the campaign parameters are the only thing separating these +// visits from direct traffic. +const BLAST_PROFILE_URL = "https://blast.tv/profile?utm_source=rockpload&utm_medium=desktop-app&utm_campaign=upload-token" type storageListConfig []*StorageConfig