Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbf7344b09 | ||
|
|
a7e75d95c4 | ||
|
|
d6f6c69c5a | ||
|
|
41ada9a506 | ||
|
|
ae2569a795 |
No files matched your search
@@ -0,0 +1,31 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Fyne butuh header X11/OpenGL dan cgo untuk dikompilasi.
|
||||||
|
- name: Install build dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
libgl1-mesa-dev libx11-dev xorg-dev libxkbcommon-dev libwayland-dev
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: go test -race ./...
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# Tinggal bikin tag: git tag v1.2.0 && git push --tags
|
||||||
|
# Versi AppImage diambil dari nama tag, jadi tidak perlu ubah file apa pun.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install build dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
libgl1-mesa-dev libx11-dev xorg-dev libxkbcommon-dev libwayland-dev
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: go test ./...
|
||||||
|
|
||||||
|
- name: Build AppImage
|
||||||
|
env:
|
||||||
|
# GH_USER/GH_REPO dipakai build.sh untuk menyisipkan info update zsync
|
||||||
|
# (gh-releases-zsync|<user>|<repo>|latest|lazyrun-*.AppImage.zsync)
|
||||||
|
GH_USER: ${{ github.repository_owner }}
|
||||||
|
GH_REPO: ${{ github.event.repository.name }}
|
||||||
|
run: |
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}" ./build.sh
|
||||||
|
ls -lh dist/lazyrun-*.AppImage*
|
||||||
|
|
||||||
|
- name: Create release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
cat > /tmp/notes.md <<'NOTES'
|
||||||
|
Single-file AppImage for Linux x86_64. No root needed, everything stays in
|
||||||
|
`~/.local/` and `~/.config/lazyrun/`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x lazyrun-*-x86_64.AppImage
|
||||||
|
./lazyrun-*-x86_64.AppImage
|
||||||
|
```
|
||||||
|
|
||||||
|
Already using LazyRun? It checks this release page on startup and offers to
|
||||||
|
update itself (delta updates with `AppImageUpdate` when installed).
|
||||||
|
|
||||||
|
---
|
||||||
|
NOTES
|
||||||
|
gh release create "$GITHUB_REF_NAME" \
|
||||||
|
dist/lazyrun-*.AppImage \
|
||||||
|
dist/lazyrun-*.AppImage.zsync \
|
||||||
|
--title "LazyRun ${GITHUB_REF_NAME}" \
|
||||||
|
--notes-file /tmp/notes.md \
|
||||||
|
--generate-notes
|
||||||
@@ -1,14 +1,30 @@
|
|||||||
# LazyRun — lazy shortcut build
|
# LazyRun — lazy shortcut build
|
||||||
|
|
||||||
|
[](https://github.com/oxpoe/lazyrun/actions/workflows/ci.yml)
|
||||||
|
[](https://github.com/oxpoe/lazyrun/releases/latest)
|
||||||
|
[](LICENSE)
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
Desktop app (Go + Fyne) that creates application shortcuts on Linux in a few clicks:
|
Desktop app (Go + Fyne) that creates application shortcuts on Linux in a few clicks:
|
||||||
from an executable file, or from a web URL as a PWA. Ships as a single-file
|
from an executable file, or from a web URL as a PWA. Ships as a single-file
|
||||||
AppImage, no terminal needed.
|
AppImage, no terminal needed.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/dialog-executable.png" width="49%" alt="New executable shortcut dialog">
|
||||||
|
<img src="docs/dialog-icons.png" width="49%" alt="Icon search dialog">
|
||||||
|
</p>
|
||||||
|
|
||||||
- **Repo:** <https://github.com/oxpoe/lazyrun>
|
- **Repo:** <https://github.com/oxpoe/lazyrun>
|
||||||
- **Donate:** <https://donasi.oxpoe.qzz.io/>
|
- **Donate:** <https://donasi.oxpoe.qzz.io/>
|
||||||
|
|
||||||
- **Executable shortcut** — picks an ELF/AppImage/script, copies it to
|
- **Executable shortcut** — picks an ELF/AppImage/script/Windows `.exe`, copies it to
|
||||||
`~/.local/bin/`, installs the icon, writes the `.desktop` entry.
|
`~/.local/bin/`, installs the icon, writes the `.desktop` entry.
|
||||||
|
- **Automatic type label** — every shortcut is tagged with what it actually is,
|
||||||
|
detected from the file: `Go`, `AppImage`, `Electron`, `Script`, `Windows`, `Binary`
|
||||||
|
(`.exe` files are launched through `wine`), or `PWA` for web apps.
|
||||||
- **Web app shortcut** — opens the URL as an app: `--app=<url>` for
|
- **Web app shortcut** — opens the URL as an app: `--app=<url>` for
|
||||||
Chromium-based browsers (Chrome, Chromium, Brave, Edge, Vivaldi, Helium,
|
Chromium-based browsers (Chrome, Chromium, Brave, Edge, Vivaldi, Helium,
|
||||||
including Flatpak builds), `--new-window` for Firefox.
|
including Flatpak builds), `--new-window` for Firefox.
|
||||||
@@ -39,6 +55,9 @@ AppImage, no terminal needed.
|
|||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
|
Needs a desktop Linux with the usual GL/X11 or Wayland libraries (any KDE Plasma or
|
||||||
|
GNOME install has them). No root, nothing outside your home directory.
|
||||||
|
|
||||||
Download the AppImage from [Releases](https://github.com/oxpoe/lazyrun/releases), then:
|
Download the AppImage from [Releases](https://github.com/oxpoe/lazyrun/releases), then:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -66,6 +85,12 @@ Keep it wherever you like (for example `~/.local/bin/`). Automatic updates need
|
|||||||
| LazyRun metadata | `~/.config/lazyrun/shortcuts.json` |
|
| LazyRun metadata | `~/.config/lazyrun/shortcuts.json` |
|
||||||
|
|
||||||
Everything stays inside the home directory — no root, no system directories.
|
Everything stays inside the home directory — no root, no system directories.
|
||||||
|
|
||||||
|
The UI follows your system light/dark setting:
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/main-light.png" width="520" alt="LazyRun in light theme">
|
||||||
|
</p>
|
||||||
Filenames use a slug of the app name (`WhatsApp Web` → `whatsapp-web.desktop`);
|
Filenames use a slug of the app name (`WhatsApp Web` → `whatsapp-web.desktop`);
|
||||||
the `Name=` field keeps the name you typed.
|
the `Name=` field keeps the name you typed.
|
||||||
|
|
||||||
@@ -82,9 +107,20 @@ start (only when running as an AppImage) and offers to install it:
|
|||||||
|
|
||||||
*Check for Updates* in the footer runs the same flow manually.
|
*Check for Updates* in the footer runs the same flow manually.
|
||||||
|
|
||||||
**Publishing a release** so updates reach users: tag it `vX.Y.Z`, upload
|
**Publishing a release** is just a tag — GitHub Actions builds and uploads
|
||||||
`lazyrun-X.Y.Z-x86_64.AppImage` plus the `.zsync` file produced by `build.sh` as
|
everything (`.github/workflows/release.yml`):
|
||||||
release assets, and mark it as the latest release.
|
|
||||||
|
```bash
|
||||||
|
git tag v1.1.0 && git push --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
The workflow builds the AppImage with the version taken from the tag, runs the
|
||||||
|
tests, and publishes a release with the `.AppImage` + `.zsync` assets and
|
||||||
|
auto-generated notes. To build a release locally instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VERSION=1.1.0 GH_USER=oxpoe GH_REPO=lazyrun ./build.sh
|
||||||
|
```
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -65,9 +66,20 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) {
|
|||||||
return sc, err
|
return sc, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// jenis aplikasi dideteksi otomatis; .exe Windows dijalankan lewat wine
|
||||||
|
kind := DetectKind(o.BinaryPath)
|
||||||
|
execArgs := []string{binDst}
|
||||||
|
if kind == KindWindows {
|
||||||
|
wine, err := exec.LookPath("wine")
|
||||||
|
if err != nil {
|
||||||
|
return sc, errors.New("this is a Windows .exe; install wine first so the shortcut can run it")
|
||||||
|
}
|
||||||
|
execArgs = []string{wine, binDst}
|
||||||
|
}
|
||||||
|
|
||||||
entry := Entry{
|
entry := Entry{
|
||||||
Name: name,
|
Name: name,
|
||||||
ExecArgs: []string{binDst},
|
ExecArgs: execArgs,
|
||||||
Icon: iconPath,
|
Icon: iconPath,
|
||||||
Categories: category(o.Category, "Utility;"),
|
Categories: category(o.Category, "Utility;"),
|
||||||
NoDisplay: !o.Menu,
|
NoDisplay: !o.Menu,
|
||||||
@@ -80,6 +92,7 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) {
|
|||||||
Slug: sl,
|
Slug: sl,
|
||||||
Name: name,
|
Name: name,
|
||||||
Type: store.TypeExecutable,
|
Type: store.TypeExecutable,
|
||||||
|
Kind: kind,
|
||||||
ExecArgs: entry.ExecArgs,
|
ExecArgs: entry.ExecArgs,
|
||||||
BinaryPath: binDst,
|
BinaryPath: binDst,
|
||||||
IconPath: iconPath,
|
IconPath: iconPath,
|
||||||
@@ -174,6 +187,7 @@ func InstallWebApp(o WebOptions) (store.Shortcut, error) {
|
|||||||
Slug: sl,
|
Slug: sl,
|
||||||
Name: name,
|
Name: name,
|
||||||
Type: store.TypeWebApp,
|
Type: store.TypeWebApp,
|
||||||
|
Kind: KindPWA,
|
||||||
URL: pageURL,
|
URL: pageURL,
|
||||||
Browser: strings.Join(o.Browser.Exec, " "),
|
Browser: strings.Join(o.Browser.Exec, " "),
|
||||||
ExecArgs: entry.ExecArgs,
|
ExecArgs: entry.ExecArgs,
|
||||||
|
|||||||
@@ -46,10 +46,12 @@ func checkRunnable(path string) error {
|
|||||||
head := make([]byte, 4)
|
head := make([]byte, 4)
|
||||||
n, _ := io.ReadFull(f, head)
|
n, _ := io.ReadFull(f, head)
|
||||||
head = head[:n]
|
head = head[:n]
|
||||||
if bytes.HasPrefix(head, []byte{0x7f, 'E', 'L', 'F'}) || bytes.HasPrefix(head, []byte("#!")) {
|
if bytes.HasPrefix(head, []byte{0x7f, 'E', 'L', 'F'}) ||
|
||||||
|
bytes.HasPrefix(head, []byte("#!")) ||
|
||||||
|
bytes.HasPrefix(head, []byte("MZ")) { // .exe Windows (dijalankan lewat wine)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return errors.New("not an executable binary (ELF, AppImage, or script only)")
|
return errors.New("not an executable (ELF, AppImage, script, or Windows .exe)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// copyFile menyalin src ke dst lewat file sementara supaya dst tidak pernah setengah tertulis.
|
// copyFile menyalin src ke dst lewat file sementara supaya dst tidak pernah setengah tertulis.
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package shortcut
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"debug/buildinfo"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Jenis aplikasi yang bisa dikenali LazyRun.
|
||||||
|
const (
|
||||||
|
KindGo = "go"
|
||||||
|
KindAppImage = "appimage"
|
||||||
|
KindElectron = "electron"
|
||||||
|
KindScript = "script"
|
||||||
|
KindWindows = "windows"
|
||||||
|
KindBinary = "binary"
|
||||||
|
KindPWA = "pwa"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DetectKind menebak jenis berkas yang dipilih user:
|
||||||
|
// binary Go, AppImage, aplikasi Electron, script, .exe Windows, atau binary biasa.
|
||||||
|
func DetectKind(path string) string {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
head := make([]byte, 12)
|
||||||
|
n, _ := f.Read(head)
|
||||||
|
f.Close()
|
||||||
|
head = head[:n]
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case bytes.HasPrefix(head, []byte("MZ")):
|
||||||
|
return KindWindows
|
||||||
|
case bytes.HasPrefix(head, []byte("#!")):
|
||||||
|
return KindScript
|
||||||
|
case isAppImageHead(head):
|
||||||
|
return KindAppImage
|
||||||
|
case bytes.HasPrefix(head, []byte{0x7f, 'E', 'L', 'F'}):
|
||||||
|
if _, err := buildinfo.ReadFile(path); err == nil {
|
||||||
|
return KindGo
|
||||||
|
}
|
||||||
|
if hasElectronResources(path) {
|
||||||
|
return KindElectron
|
||||||
|
}
|
||||||
|
return KindBinary
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasElectronResources mencari berkas penanda Electron di sebelah binary.
|
||||||
|
func hasElectronResources(path string) bool {
|
||||||
|
resources := filepath.Join(filepath.Dir(path), "resources")
|
||||||
|
for _, name := range []string{"app.asar", "default_app.asar", "app"} {
|
||||||
|
if _, err := os.Stat(filepath.Join(resources, name)); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAppImageHead memeriksa magic AppImage (ELF + "AI" pada offset 8).
|
||||||
|
// Sengaja terpisah dari internal/update supaya paket ini tidak tergantung ke sana.
|
||||||
|
func isAppImageHead(head []byte) bool {
|
||||||
|
return len(head) >= 11 &&
|
||||||
|
head[0] == 0x7f && head[1] == 'E' && head[2] == 'L' && head[3] == 'F' &&
|
||||||
|
head[8] == 'A' && head[9] == 'I' && (head[10] == 0x01 || head[10] == 0x02)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package shortcut
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetectKind(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
write := func(name string, head []byte) string {
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
if err := os.WriteFile(path, append(head, make([]byte, 64)...), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
elf := []byte{0x7f, 'E', 'L', 'F', 0x02, 0x01, 0x01, 0x00}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"windows exe", write("winbox.exe", []byte("MZ\x90\x00")), KindWindows},
|
||||||
|
{"script", write("tool.sh", []byte("#!/bin/sh\n")), KindScript},
|
||||||
|
{"appimage", write("app.AppImage", append(append([]byte{}, elf...), 'A', 'I', 0x02, 0x00)), KindAppImage},
|
||||||
|
{"binary biasa", write("binary", elf), KindBinary},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := DetectKind(c.path); got != c.want {
|
||||||
|
t.Errorf("DetectKind() = %q, mau %q", got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// binary Go: test ini sendiri hasil build Go, jadi harus terdeteksi "go"
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := DetectKind(exe); got != KindGo {
|
||||||
|
t.Errorf("DetectKind(test binary) = %q, mau %q", got, KindGo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectKindElectron(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
app := filepath.Join(dir, "myapp")
|
||||||
|
if err := os.WriteFile(app, append([]byte{0x7f, 'E', 'L', 'F', 0x02, 0x01, 0x01, 0x00}, make([]byte, 32)...), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, "resources"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "resources", "app.asar"), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := DetectKind(app); got != KindElectron {
|
||||||
|
t.Errorf("DetectKind(electron) = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectKindUnknownFile(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "catatan.txt")
|
||||||
|
if err := os.WriteFile(path, []byte("halo"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := DetectKind(path); got != "" {
|
||||||
|
t.Errorf("teks biasa tidak punya kind, dapat %q", got)
|
||||||
|
}
|
||||||
|
if got := DetectKind(filepath.Join(t.TempDir(), "tidak-ada")); got != "" {
|
||||||
|
t.Errorf("berkas tidak ada harus kosong, dapat %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ type Shortcut struct {
|
|||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
Kind string `json:"kind,omitempty"` // hasil auto-detect: go, appimage, electron, script, windows, binary, pwa
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
Browser string `json:"browser,omitempty"`
|
Browser string `json:"browser,omitempty"`
|
||||||
ExecArgs []string `json:"exec_args,omitempty"`
|
ExecArgs []string `json:"exec_args,omitempty"`
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func (ui *app) showExecutableDialog() {
|
|||||||
browseBin := widget.NewButtonWithIcon(T("browse"), theme.FolderOpenIcon(), func() {
|
browseBin := widget.NewButtonWithIcon(T("browse"), theme.FolderOpenIcon(), func() {
|
||||||
pickFile(ui.win, nil, func(path string) {
|
pickFile(ui.win, nil, func(path string) {
|
||||||
binPath = path
|
binPath = path
|
||||||
binLabel.SetText(filepath.Base(path))
|
binLabel.SetText(filepath.Base(path) + " · " + detectedKind(path))
|
||||||
if strings.TrimSpace(nameEntry.Text) == "" {
|
if strings.TrimSpace(nameEntry.Text) == "" {
|
||||||
nameEntry.SetText(appNameFromFile(path))
|
nameEntry.SetText(appNameFromFile(path))
|
||||||
}
|
}
|
||||||
@@ -136,6 +136,15 @@ func pickFile(win fyne.Window, filter storage.FileFilter, onPick func(path strin
|
|||||||
fd.Show()
|
fd.Show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// detectedKind menampilkan hasil auto-detect jenis berkas di dialog.
|
||||||
|
func detectedKind(path string) string {
|
||||||
|
kind := shortcut.DetectKind(path)
|
||||||
|
if kind == "" {
|
||||||
|
return T("kind_unknown")
|
||||||
|
}
|
||||||
|
return kind
|
||||||
|
}
|
||||||
|
|
||||||
func appNameFromFile(path string) string {
|
func appNameFromFile(path string) string {
|
||||||
base := filepath.Base(path)
|
base := filepath.Base(path)
|
||||||
for _, ext := range stripExts {
|
for _, ext := range stripExts {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type app struct {
|
|||||||
cards map[string]*hoverCard
|
cards map[string]*hoverCard
|
||||||
icons *iconCache
|
icons *iconCache
|
||||||
count *canvas.Text // angka jumlah shortcut di header daftar
|
count *canvas.Text // angka jumlah shortcut di header daftar
|
||||||
|
versionText *canvas.Text // versi di footer; berubah kalau ada update
|
||||||
version string
|
version string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ func build(a fyne.App, version string) (*app, error) {
|
|||||||
ui.count = count
|
ui.count = count
|
||||||
ui.win = a.NewWindow(T("app_title"))
|
ui.win = a.NewWindow(T("app_title"))
|
||||||
ui.win.Resize(fyne.NewSize(840, 600))
|
ui.win.Resize(fyne.NewSize(840, 600))
|
||||||
|
ui.versionText = caption("LazyRun v" + version + " · oxpoe")
|
||||||
|
|
||||||
st, err := store.Load(xdg.MetadataPath())
|
st, err := store.Load(xdg.MetadataPath())
|
||||||
ui.store = st
|
ui.store = st
|
||||||
@@ -82,7 +84,7 @@ func build(a fyne.App, version string) (*app, error) {
|
|||||||
footer := container.NewBorder(
|
footer := container.NewBorder(
|
||||||
widget.NewSeparator(), nil,
|
widget.NewSeparator(), nil,
|
||||||
container.NewHBox(update, github, donate),
|
container.NewHBox(update, github, donate),
|
||||||
caption("LazyRun v"+version+" · oxpoe"),
|
ui.versionText,
|
||||||
)
|
)
|
||||||
|
|
||||||
accent := accentBar()
|
accent := accentBar()
|
||||||
@@ -129,7 +131,8 @@ func (ui *app) row(sc store.Shortcut) *hoverCard {
|
|||||||
caption(metaText(sc)),
|
caption(metaText(sc)),
|
||||||
)
|
)
|
||||||
|
|
||||||
chips := make([]fyne.CanvasObject, 0, 3)
|
chips := make([]fyne.CanvasObject, 0, 4)
|
||||||
|
chips = append(chips, chip(kindLabel(sc), colorNameChipKind))
|
||||||
if sc.Targets.Menu {
|
if sc.Targets.Menu {
|
||||||
chips = append(chips, chip(T("chip_menu"), colorNameChipMenu))
|
chips = append(chips, chip(T("chip_menu"), colorNameChipMenu))
|
||||||
}
|
}
|
||||||
@@ -157,18 +160,44 @@ func (ui *app) row(sc store.Shortcut) *hoverCard {
|
|||||||
actions))
|
actions))
|
||||||
}
|
}
|
||||||
|
|
||||||
// metaText menampilkan tipe shortcut dan penanda singkatnya.
|
// kindLabel adalah label jenis aplikasi hasil auto-detect.
|
||||||
|
// Istilahnya teknis (nama teknologi), jadi tidak diterjemahkan.
|
||||||
|
func kindLabel(sc store.Shortcut) string {
|
||||||
|
switch sc.Kind {
|
||||||
|
case shortcut.KindGo:
|
||||||
|
return "Go"
|
||||||
|
case shortcut.KindAppImage:
|
||||||
|
return "AppImage"
|
||||||
|
case shortcut.KindElectron:
|
||||||
|
return "Electron"
|
||||||
|
case shortcut.KindScript:
|
||||||
|
return "Script"
|
||||||
|
case shortcut.KindWindows:
|
||||||
|
return "Windows"
|
||||||
|
case shortcut.KindBinary:
|
||||||
|
return "Binary"
|
||||||
|
case shortcut.KindPWA:
|
||||||
|
return "PWA"
|
||||||
|
}
|
||||||
|
// shortcut lama (sebelum ada auto-detect)
|
||||||
|
if sc.Type == store.TypeWebApp {
|
||||||
|
return "PWA"
|
||||||
|
}
|
||||||
|
return "Executable"
|
||||||
|
}
|
||||||
|
|
||||||
|
// metaText menampilkan penanda singkat: nama berkas binary atau host web app.
|
||||||
func metaText(sc store.Shortcut) string {
|
func metaText(sc store.Shortcut) string {
|
||||||
if sc.Type == store.TypeWebApp {
|
if sc.Type == store.TypeWebApp {
|
||||||
if host := hostOf(sc.URL); host != "" {
|
if host := hostOf(sc.URL); host != "" {
|
||||||
return "Web · " + host
|
return host
|
||||||
}
|
}
|
||||||
return "Web"
|
return sc.URL
|
||||||
}
|
}
|
||||||
if sc.BinaryPath != "" {
|
if sc.BinaryPath != "" {
|
||||||
return "Executable · " + filepath.Base(sc.BinaryPath)
|
return filepath.Base(sc.BinaryPath)
|
||||||
}
|
}
|
||||||
return "Executable"
|
return sc.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
func fallbackIcon(sc store.Shortcut) fyne.Resource {
|
func fallbackIcon(sc store.Shortcut) fyne.Resource {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"fyne.io/fyne/v2/test"
|
"fyne.io/fyne/v2/test"
|
||||||
|
|
||||||
|
"lazyrun/internal/shortcut"
|
||||||
"lazyrun/internal/store"
|
"lazyrun/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,11 +29,37 @@ func TestBuildMainWindow(t *testing.T) {
|
|||||||
|
|
||||||
func TestMetaText(t *testing.T) {
|
func TestMetaText(t *testing.T) {
|
||||||
sc := store.Shortcut{Type: store.TypeWebApp, URL: "https://example.com/x", Targets: store.Targets{Menu: true}}
|
sc := store.Shortcut{Type: store.TypeWebApp, URL: "https://example.com/x", Targets: store.Targets{Menu: true}}
|
||||||
if got := metaText(sc); got != "Web · example.com" {
|
if got := metaText(sc); got != "example.com" {
|
||||||
t.Errorf("metaText() = %q", got)
|
t.Errorf("metaText() = %q", got)
|
||||||
}
|
}
|
||||||
exe := store.Shortcut{Type: store.TypeExecutable, BinaryPath: "/home/u/.local/bin/winbox"}
|
exe := store.Shortcut{Type: store.TypeExecutable, BinaryPath: "/home/u/.local/bin/winbox"}
|
||||||
if got := metaText(exe); got != "Executable · winbox" {
|
if got := metaText(exe); got != "winbox" {
|
||||||
t.Errorf("metaText() = %q", got)
|
t.Errorf("metaText() = %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestKindLabel(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
shortcut.KindGo: "Go",
|
||||||
|
shortcut.KindAppImage: "AppImage",
|
||||||
|
shortcut.KindElectron: "Electron",
|
||||||
|
shortcut.KindScript: "Script",
|
||||||
|
shortcut.KindWindows: "Windows",
|
||||||
|
shortcut.KindBinary: "Binary",
|
||||||
|
shortcut.KindPWA: "PWA",
|
||||||
|
}
|
||||||
|
for kind, want := range cases {
|
||||||
|
sc := store.Shortcut{Kind: kind, Type: store.TypeExecutable}
|
||||||
|
if got := kindLabel(sc); got != want {
|
||||||
|
t.Errorf("kindLabel(%q) = %q, mau %q", kind, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// data lama tanpa kind
|
||||||
|
if got := kindLabel(store.Shortcut{Type: store.TypeExecutable}); got != "Executable" {
|
||||||
|
t.Errorf("kindLabel(lama, executable) = %q", got)
|
||||||
|
}
|
||||||
|
if got := kindLabel(store.Shortcut{Type: store.TypeWebApp}); got != "PWA" {
|
||||||
|
t.Errorf("kindLabel(lama, webapp) = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ var tr = map[string][2]string{
|
|||||||
"chip_desktop": {"Desktop", "Desktop"},
|
"chip_desktop": {"Desktop", "Desktop"},
|
||||||
"chip_broken": {"file missing", "file hilang"},
|
"chip_broken": {"file missing", "file hilang"},
|
||||||
"none": {"not selected", "belum dipilih"},
|
"none": {"not selected", "belum dipilih"},
|
||||||
|
"kind_unknown": {"unknown type", "jenis tidak dikenal"},
|
||||||
"icon_auto": {"automatic", "otomatis"},
|
"icon_auto": {"automatic", "otomatis"},
|
||||||
"online": {"Online", "Online"},
|
"online": {"Online", "Online"},
|
||||||
"search": {"Search", "Cari"},
|
"search": {"Search", "Cari"},
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const (
|
|||||||
colorNameChipMenu fyne.ThemeColorName = "lazyrunChipMenu"
|
colorNameChipMenu fyne.ThemeColorName = "lazyrunChipMenu"
|
||||||
colorNameChipDesk fyne.ThemeColorName = "lazyrunChipDesktop"
|
colorNameChipDesk fyne.ThemeColorName = "lazyrunChipDesktop"
|
||||||
colorNameChipBroken fyne.ThemeColorName = "lazyrunChipBroken"
|
colorNameChipBroken fyne.ThemeColorName = "lazyrunChipBroken"
|
||||||
|
colorNameChipKind fyne.ThemeColorName = "lazyrunChipKind"
|
||||||
)
|
)
|
||||||
|
|
||||||
// brand menimpa sebagian warna/ukuran tema bawaan; terang/gelap tetap ikut sistem.
|
// brand menimpa sebagian warna/ukuran tema bawaan; terang/gelap tetap ikut sistem.
|
||||||
@@ -51,6 +52,8 @@ func (b brand) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.
|
|||||||
return withAlpha(brandIndigo, pick[uint8](dark, 0x44, 0x26))
|
return withAlpha(brandIndigo, pick[uint8](dark, 0x44, 0x26))
|
||||||
case colorNameChipDesk:
|
case colorNameChipDesk:
|
||||||
return withAlpha(brandCyan, pick[uint8](dark, 0x3D, 0x2C))
|
return withAlpha(brandCyan, pick[uint8](dark, 0x3D, 0x2C))
|
||||||
|
case colorNameChipKind:
|
||||||
|
return pick(dark, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0x1C}, color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x12})
|
||||||
case colorNameChipBroken:
|
case colorNameChipBroken:
|
||||||
return withAlpha(color.NRGBA{R: 0xE0, G: 0x4F, B: 0x4F, A: 0xFF}, pick[uint8](dark, 0x4A, 0x2E))
|
return withAlpha(color.NRGBA{R: 0xE0, G: 0x4F, B: 0x4F, A: 0xFF}, pick[uint8](dark, 0x4A, 0x2E))
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-7
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
@@ -15,15 +16,45 @@ import (
|
|||||||
"lazyrun/internal/update"
|
"lazyrun/internal/update"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// recheckInterval adalah jeda pemeriksaan update berkala selama aplikasi terbuka.
|
||||||
|
const recheckInterval = 4 * time.Hour
|
||||||
|
|
||||||
|
// appImagePath mengembalikan lokasi AppImage yang sedang dipakai.
|
||||||
|
// $APPIMAGE dipakai kalau ada; kalau tidak (mis. dijalankan lewat wrapper),
|
||||||
|
// berkas executable-nya sendiri diperiksa apakah benar AppImage.
|
||||||
|
func appImagePath() string {
|
||||||
|
if path := os.Getenv("APPIMAGE"); path != "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
|
||||||
|
exe = resolved
|
||||||
|
}
|
||||||
|
if update.IsAppImage(exe) {
|
||||||
|
return exe
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// checkUpdates dipanggil tombol footer: hasilnya selalu diberitahukan.
|
// checkUpdates dipanggil tombol footer: hasilnya selalu diberitahukan.
|
||||||
func (ui *app) checkUpdates() { ui.checkForUpdate(true) }
|
func (ui *app) checkUpdates() { ui.checkForUpdate(true) }
|
||||||
|
|
||||||
// scheduleUpdateCheck memeriksa rilis terbaru beberapa saat setelah aplikasi tampil.
|
// scheduleUpdateCheck memeriksa rilis terbaru setelah aplikasi tampil,
|
||||||
|
// lalu mengulanginya berkala selama aplikasi terbuka.
|
||||||
func (ui *app) scheduleUpdateCheck() {
|
func (ui *app) scheduleUpdateCheck() {
|
||||||
if os.Getenv("APPIMAGE") == "" {
|
if appImagePath() == "" {
|
||||||
return // dijalankan dari source: jangan ganggu dengan cek update
|
return // dijalankan dari source / hasil ekstrak: tidak bisa memasang update
|
||||||
}
|
}
|
||||||
time.AfterFunc(2*time.Second, func() { ui.checkForUpdate(false) })
|
time.AfterFunc(2*time.Second, func() { ui.checkForUpdate(false) })
|
||||||
|
ticker := time.NewTicker(recheckInterval)
|
||||||
|
go func() {
|
||||||
|
for range ticker.C {
|
||||||
|
ui.checkForUpdate(false)
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkForUpdate mengambil rilis terbaru dari GitHub. manual=false hanya
|
// checkForUpdate mengambil rilis terbaru dari GitHub. manual=false hanya
|
||||||
@@ -45,6 +76,7 @@ func (ui *app) checkForUpdate(manual bool) {
|
|||||||
dialog.ShowError(err, ui.win)
|
dialog.ShowError(err, ui.win)
|
||||||
}
|
}
|
||||||
case update.Newer(ui.version, rel):
|
case update.Newer(ui.version, rel):
|
||||||
|
ui.markUpdatePending(rel)
|
||||||
ui.showUpdateDialog(rel)
|
ui.showUpdateDialog(rel)
|
||||||
case manual:
|
case manual:
|
||||||
dialog.ShowInformation(T("check_updates"), fmt.Sprintf(T("up_to_date"), ui.version), ui.win)
|
dialog.ShowInformation(T("check_updates"), fmt.Sprintf(T("up_to_date"), ui.version), ui.win)
|
||||||
@@ -53,6 +85,16 @@ func (ui *app) checkForUpdate(manual bool) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// markUpdatePending menampilkan versi baru di footer, jadi tetap terlihat
|
||||||
|
// walau dialognya ditutup dengan "Nanti".
|
||||||
|
func (ui *app) markUpdatePending(rel update.Release) {
|
||||||
|
if ui.versionText == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ui.versionText.Text = fmt.Sprintf("v%s → v%s", ui.version, rel.Version)
|
||||||
|
ui.versionText.Refresh()
|
||||||
|
}
|
||||||
|
|
||||||
// showUpdateDialog menawarkan update: unduh sekarang, buka halaman rilis, atau nanti.
|
// showUpdateDialog menawarkan update: unduh sekarang, buka halaman rilis, atau nanti.
|
||||||
func (ui *app) showUpdateDialog(rel update.Release) {
|
func (ui *app) showUpdateDialog(rel update.Release) {
|
||||||
notes := widget.NewLabel(rel.Notes)
|
notes := widget.NewLabel(rel.Notes)
|
||||||
@@ -91,7 +133,7 @@ func (ui *app) showUpdateDialog(rel update.Release) {
|
|||||||
|
|
||||||
// updaterBinary mengembalikan path AppImageUpdate kalau terpasang.
|
// updaterBinary mengembalikan path AppImageUpdate kalau terpasang.
|
||||||
func (ui *app) updaterBinary() string {
|
func (ui *app) updaterBinary() string {
|
||||||
if os.Getenv("APPIMAGE") == "" {
|
if appImagePath() == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
path, err := exec.LookPath("AppImageUpdate")
|
path, err := exec.LookPath("AppImageUpdate")
|
||||||
@@ -103,7 +145,7 @@ func (ui *app) updaterBinary() string {
|
|||||||
|
|
||||||
// downloadUpdate mengunduh AppImage terbaru dan menukarnya dengan yang sedang dipakai.
|
// downloadUpdate mengunduh AppImage terbaru dan menukarnya dengan yang sedang dipakai.
|
||||||
func (ui *app) downloadUpdate(rel update.Release) {
|
func (ui *app) downloadUpdate(rel update.Release) {
|
||||||
appImage := os.Getenv("APPIMAGE")
|
appImage := appImagePath()
|
||||||
if appImage == "" {
|
if appImage == "" {
|
||||||
ui.openURL(rel.PageURL)
|
ui.openURL(rel.PageURL)
|
||||||
return
|
return
|
||||||
@@ -126,10 +168,16 @@ func (ui *app) downloadUpdate(rel update.Release) {
|
|||||||
|
|
||||||
// runUpdater memakai AppImageUpdate untuk memasang update (pakai zsync, unduhannya kecil).
|
// runUpdater memakai AppImageUpdate untuk memasang update (pakai zsync, unduhannya kecil).
|
||||||
func (ui *app) runUpdater(updater string, rel update.Release) {
|
func (ui *app) runUpdater(updater string, rel update.Release) {
|
||||||
|
appImage := appImagePath()
|
||||||
|
if appImage == "" {
|
||||||
|
ui.openURL(rel.PageURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
progress := dialog.NewProgressInfinite(T("check_updates"), T("update_downloading"), ui.win)
|
progress := dialog.NewProgressInfinite(T("check_updates"), T("update_downloading"), ui.win)
|
||||||
progress.Show()
|
progress.Show()
|
||||||
go func() {
|
go func() {
|
||||||
err := exec.Command(updater, os.Getenv("APPIMAGE")).Run()
|
err := exec.Command(updater, appImage).Run()
|
||||||
fyne.Do(func() {
|
fyne.Do(func() {
|
||||||
progress.Hide()
|
progress.Hide()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -157,7 +205,7 @@ func (ui *app) offerRestart() {
|
|||||||
|
|
||||||
// restart menjalankan AppImage yang baru lalu menutup proses lama.
|
// restart menjalankan AppImage yang baru lalu menutup proses lama.
|
||||||
func (ui *app) restart() {
|
func (ui *app) restart() {
|
||||||
appImage := os.Getenv("APPIMAGE")
|
appImage := appImagePath()
|
||||||
if appImage == "" {
|
if appImage == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2/test"
|
||||||
|
|
||||||
|
"lazyrun/internal/update"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAppImagePathFromEnv(t *testing.T) {
|
||||||
|
t.Setenv("APPIMAGE", "/home/u/Apps/lazyrun-1.0.0-x86_64.AppImage")
|
||||||
|
if got := appImagePath(); got != "/home/u/Apps/lazyrun-1.0.0-x86_64.AppImage" {
|
||||||
|
t.Errorf("appImagePath() = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppImagePathWithoutEnvAndNotAppImage(t *testing.T) {
|
||||||
|
// test binary bukan AppImage, jadi tidak ada yang bisa diupdate
|
||||||
|
t.Setenv("APPIMAGE", "")
|
||||||
|
if got := appImagePath(); got != "" {
|
||||||
|
t.Errorf("appImagePath() = %q, mau kosong untuk binary biasa", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppImagePathFallsBackToExecutable(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// executable tiruan: ELF + magic AI (AppImage)
|
||||||
|
fake := filepath.Join(dir, "lazyrun.AppImage")
|
||||||
|
data := make([]byte, 64)
|
||||||
|
copy(data, []byte{0x7f, 'E', 'L', 'F', 0x02, 0x01, 0x01, 0x00})
|
||||||
|
copy(data[8:], []byte{'A', 'I', 0x02})
|
||||||
|
if err := os.WriteFile(fake, data, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// jalur env kosong -> deteksi dari IsAppImage (di sini diuji langsung)
|
||||||
|
if !update.IsAppImage(fake) {
|
||||||
|
t.Fatal("berkas uji harus dikenali sebagai AppImage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkUpdatePendingSetsFooter(t *testing.T) {
|
||||||
|
a := test.NewApp()
|
||||||
|
defer a.Quit()
|
||||||
|
|
||||||
|
ui, err := build(a, "1.1.0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ui.markUpdatePending(update.Release{Version: "9.9.9"})
|
||||||
|
if got := ui.versionText.Text; got != "v1.1.0 → v9.9.9" {
|
||||||
|
t.Errorf("footer tidak menampilkan update: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user