Ikon: cari per domain/URL gambar + semua format dikonversi ke PNG
- Dialog ikon: nama aplikasi -> Iconify; domain (mikrotik.com) -> ikon resmi situs (manifest 512, apple-touch-icon, favicon) + layanan favicon; URL gambar -> langsung - Contoh kasus: 'winbox' cuma punya 1 ikon tidak nyambung di Iconify, tapi 'mikrotik.com' memberi ikon resminya (5 pilihan, semuanya PNG) - Decoder ICO: entri BMP 24/32-bit (favicon.ico lama) ikut didukung, bukan cuma PNG - FromURL/Fetch selalu mengembalikan PNG; hasil raster/konversi seragam 512px - Tes: deteksi URL gambar/domain, daftar ikon situs, decode ICO BMP, konversi format
This commit is contained in:
1 parent
cad41caaee
commit
59145910b5
9 files changed
+441
-33
No files matched your search
@@ -12,12 +12,16 @@ AppImage, no terminal needed.
|
|||||||
- **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.
|
||||||
- **Icons** — three ways: pick a file, search an online icon library
|
- **Icons** — pick a file, or search in the *Icon from the internet* dialog:
|
||||||
([Iconify](https://iconify.design), with the app name as the query), or let LazyRun
|
- **app name** (`whatsapp`) → search of the [Iconify](https://iconify.design) icon library;
|
||||||
do it automatically: a nearby image file, the site's favicon (including high
|
- **website** (`mikrotik.com`) → the site's own icons: web app manifest (up to
|
||||||
resolution icons from the web app manifest), or a brand icon matched by name.
|
512 px), apple-touch-icon, favicon, plus a favicon service. Best option for apps
|
||||||
Every downloaded icon remembers its source, so the *sync icon* button in the list
|
that have no icon in icon libraries (e.g. Winbox → `mikrotik.com`);
|
||||||
can fetch it again later.
|
- **image URL** (`https://…/icon.png`) → that image directly.
|
||||||
|
Installation also tries automatically: a nearby image file, the site's icons, then a
|
||||||
|
brand icon matched by name. Every icon is converted to PNG (SVG, webp, gif, bmp,
|
||||||
|
ico and jpeg included) at up to 512 px, and remembers its source so the *sync icon*
|
||||||
|
button in the list can fetch it again later.
|
||||||
- **Shortcut location** — checkbox for *Application menu* and/or *Desktop*,
|
- **Shortcut location** — checkbox for *Application menu* and/or *Desktop*,
|
||||||
at least one is required. Desktop-only entries are written with
|
at least one is required. Desktop-only entries are written with
|
||||||
`NoDisplay=true` so they do not show up in the menu.
|
`NoDisplay=true` so they do not show up in the menu.
|
||||||
@@ -56,7 +60,7 @@ Keep it wherever you like (for example `~/.local/bin/`). Automatic updates need
|
|||||||
| Purpose | Path |
|
| Purpose | Path |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Copied binary | `~/.local/bin/<name>` |
|
| Copied binary | `~/.local/bin/<name>` |
|
||||||
| Icon | `~/.local/share/icons/hicolor/512x512/apps/<name>.png` |
|
| Icon (always PNG) | `~/.local/share/icons/hicolor/512x512/apps/<name>.png` |
|
||||||
| Menu entry (source of truth) | `~/.local/share/applications/<name>.desktop` |
|
| Menu entry (source of truth) | `~/.local/share/applications/<name>.desktop` |
|
||||||
| Desktop copy | `~/Desktop/<name>.desktop` (from `xdg-user-dir DESKTOP`) |
|
| Desktop copy | `~/Desktop/<name>.desktop` (from `xdg-user-dir DESKTOP`) |
|
||||||
| LazyRun metadata | `~/.config/lazyrun/shortcuts.json` |
|
| LazyRun metadata | `~/.config/lazyrun/shortcuts.json` |
|
||||||
|
|||||||
+79
-14
@@ -36,18 +36,17 @@ func Fetch(pageURL string) ([]byte, string, error) {
|
|||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for _, candidate := range iconCandidates(base) {
|
for _, candidate := range iconCandidates(base) {
|
||||||
data, err := get(candidate)
|
data, err := get(candidate.url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if ext := sniffExt(data); ext != "" {
|
converted, err := ToPNG(data)
|
||||||
return data, ext, nil
|
if err != nil {
|
||||||
|
lastErr = fmt.Errorf("%s: %w", candidate.url, err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if png := largestPNGFromICO(data); png != nil {
|
return converted, ".png", nil
|
||||||
return png, ".png", nil
|
|
||||||
}
|
|
||||||
lastErr = errors.New("unsupported icon format at " + candidate)
|
|
||||||
}
|
}
|
||||||
if lastErr == nil {
|
if lastErr == nil {
|
||||||
lastErr = errors.New("no favicon found")
|
lastErr = errors.New("no favicon found")
|
||||||
@@ -105,24 +104,86 @@ func manifestIcons(page *url.URL) []iconCandidate {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
score := 0
|
score, label := 0, "manifest"
|
||||||
|
if entry.Sizes != "" {
|
||||||
|
label = "manifest " + entry.Sizes
|
||||||
|
}
|
||||||
if size := attr(sizeAttr, `sizes="`+entry.Sizes+`"`); size != "" {
|
if size := attr(sizeAttr, `sizes="`+entry.Sizes+`"`); size != "" {
|
||||||
fmt.Sscanf(size, "%d", &score)
|
fmt.Sscanf(size, "%d", &score)
|
||||||
}
|
}
|
||||||
candidates = append(candidates, iconCandidate{base.ResolveReference(ref).String(), score})
|
candidates = append(candidates, iconCandidate{url: base.ResolveReference(ref).String(), label: label, score: score})
|
||||||
}
|
}
|
||||||
sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].score > candidates[j].score })
|
sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].score > candidates[j].score })
|
||||||
return candidates
|
return candidates
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SiteIcons mengembalikan semua ikon yang ditawarkan sebuah situs (manifest,
|
||||||
|
// apple-touch-icon, favicon) plus versi dari layanan favicon.
|
||||||
|
// Dipakai untuk memasang ikon aplikasi dari domain resminya.
|
||||||
|
func SiteIcons(pageURL string) ([]Result, error) {
|
||||||
|
base, err := normalizePageURL(pageURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []Result
|
||||||
|
seen := map[string]bool{}
|
||||||
|
add := func(c iconCandidate) {
|
||||||
|
if c.url == "" || seen[c.url] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[c.url] = true
|
||||||
|
out = append(out, Result{Name: c.label, URL: c.url})
|
||||||
|
}
|
||||||
|
for _, c := range iconCandidates(base) {
|
||||||
|
add(c)
|
||||||
|
}
|
||||||
|
if base.Hostname() != "" {
|
||||||
|
add(faviconService(base.Hostname()))
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, errors.New("situs ini tidak menyediakan ikon")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// faviconService memakai layanan favicon Google sebagai pilihan tambahan.
|
||||||
|
func faviconService(domain string) iconCandidate {
|
||||||
|
return iconCandidate{
|
||||||
|
url: fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=128", url.QueryEscape(domain)),
|
||||||
|
label: "google " + domain,
|
||||||
|
score: 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizePageURL menambah skema https kalau user menulis domain saja.
|
||||||
|
func normalizePageURL(raw string) (*url.URL, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return nil, errors.New("alamat kosong")
|
||||||
|
}
|
||||||
|
if !strings.Contains(raw, "://") {
|
||||||
|
raw = "https://" + raw
|
||||||
|
}
|
||||||
|
base, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if base.Host == "" {
|
||||||
|
return nil, errors.New("domain tidak valid: " + raw)
|
||||||
|
}
|
||||||
|
return base, nil
|
||||||
|
}
|
||||||
|
|
||||||
// iconCandidate adalah kandidat URL ikon dengan skor prioritas.
|
// iconCandidate adalah kandidat URL ikon dengan skor prioritas.
|
||||||
type iconCandidate struct {
|
type iconCandidate struct {
|
||||||
url string
|
url string
|
||||||
|
label string // keterangan untuk ditampilkan di UI (mis. "manifest 512x512")
|
||||||
score int
|
score int
|
||||||
}
|
}
|
||||||
|
|
||||||
// iconCandidates mengumpulkan URL ikon dari HTML, ikon besar didahulukan, favicon.ico terakhir.
|
// iconCandidates mengumpulkan URL ikon dari HTML, ikon besar didahulukan, favicon.ico terakhir.
|
||||||
func iconCandidates(base *url.URL) []string {
|
func iconCandidates(base *url.URL) []iconCandidate {
|
||||||
var found []iconCandidate
|
var found []iconCandidate
|
||||||
|
|
||||||
if page, err := get(base.String()); err == nil {
|
if page, err := get(base.String()); err == nil {
|
||||||
@@ -147,24 +208,28 @@ func iconCandidates(base *url.URL) []string {
|
|||||||
fmt.Sscanf(size, "%d", &score)
|
fmt.Sscanf(size, "%d", &score)
|
||||||
score += 10
|
score += 10
|
||||||
}
|
}
|
||||||
found = append(found, iconCandidate{base.ResolveReference(ref).String(), score})
|
label := rel
|
||||||
|
if size := attr(sizeAttr, tag); size != "" {
|
||||||
|
label += " " + size
|
||||||
|
}
|
||||||
|
found = append(found, iconCandidate{url: base.ResolveReference(ref).String(), label: label, score: score})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.SliceStable(found, func(i, j int) bool { return found[i].score > found[j].score })
|
sort.SliceStable(found, func(i, j int) bool { return found[i].score > found[j].score })
|
||||||
found = append(manifestIcons(base), found...)
|
found = append(manifestIcons(base), found...)
|
||||||
|
|
||||||
out := make([]string, 0, len(found)+1)
|
out := make([]iconCandidate, 0, len(found)+1)
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
for _, c := range found {
|
for _, c := range found {
|
||||||
if !seen[c.url] {
|
if !seen[c.url] {
|
||||||
seen[c.url] = true
|
seen[c.url] = true
|
||||||
out = append(out, c.url)
|
out = append(out, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fallback := base.ResolveReference(&url.URL{Path: "/favicon.ico"}).String()
|
fallback := base.ResolveReference(&url.URL{Path: "/favicon.ico"}).String()
|
||||||
if !seen[fallback] {
|
if !seen[fallback] {
|
||||||
out = append(out, fallback)
|
out = append(out, iconCandidate{url: fallback, label: "favicon.ico"})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package icon
|
package icon
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -23,7 +27,17 @@ func icoWithPNG(png []byte) []byte {
|
|||||||
return ico
|
return ico
|
||||||
}
|
}
|
||||||
|
|
||||||
var pngBytes = append([]byte("\x89PNG\r\n\x1a\n"), []byte("payload")...)
|
// pngBytes adalah PNG asli (kecil) untuk tes: ICO yang berisi PNG harus benar-benar
|
||||||
|
// bisa di-decode, bukan sekadar punya magic bytes.
|
||||||
|
var pngBytes = func() []byte {
|
||||||
|
img := image.NewNRGBA(image.Rect(0, 0, 8, 8))
|
||||||
|
img.Set(4, 4, color.NRGBA{R: 0x20, G: 0x80, B: 0xE0, A: 0xFF})
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}()
|
||||||
|
|
||||||
func TestFetchUsesLinkedIcon(t *testing.T) {
|
func TestFetchUsesLinkedIcon(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package icon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
)
|
||||||
|
|
||||||
|
// decodeICO mengambil gambar terbesar di dalam file .ico.
|
||||||
|
//
|
||||||
|
// ICO bisa berisi PNG (modern) atau DIB/BMP (format lama, masih sering dipakai
|
||||||
|
// favicon). Keduanya didukung: BMP 32-bit pakai alpha, 24-bit pakai AND mask.
|
||||||
|
// Entri dengan palet (<= 8-bit) tidak didukung.
|
||||||
|
func decodeICO(data []byte) (image.Image, error) {
|
||||||
|
if pngData := largestPNGFromICO(data); pngData != nil {
|
||||||
|
return png.Decode(bytes.NewReader(pngData))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) < 22 || data[0] != 0 || data[1] != 0 || data[2] != 1 || data[3] != 0 {
|
||||||
|
return nil, errors.New("bukan file ICO")
|
||||||
|
}
|
||||||
|
count := int(data[4]) | int(data[5])<<8
|
||||||
|
|
||||||
|
var best image.Image
|
||||||
|
bestSize := 0
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
off := 6 + i*16
|
||||||
|
if off+16 > len(data) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
width := int(data[off])
|
||||||
|
if width == 0 {
|
||||||
|
width = 256
|
||||||
|
}
|
||||||
|
size := int(data[off+8]) | int(data[off+9])<<8 | int(data[off+10])<<16 | int(data[off+11])<<24
|
||||||
|
start := int(data[off+12]) | int(data[off+13])<<8 | int(data[off+14])<<16 | int(data[off+15])<<24
|
||||||
|
if size <= 0 || start <= 0 || start+size > len(data) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if width*width <= bestSize {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
img, err := decodeDIB(data[start : start+size])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
best, bestSize = img, width*width
|
||||||
|
}
|
||||||
|
if best == nil {
|
||||||
|
return nil, errors.New("tidak ada gambar yang bisa dibaca di dalam .ico")
|
||||||
|
}
|
||||||
|
return best, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeDIB membaca gambar DIB/BMP di dalam entri ICO.
|
||||||
|
func decodeDIB(dib []byte) (image.Image, error) {
|
||||||
|
if len(dib) < 40 {
|
||||||
|
return nil, errors.New("entri ICO terlalu pendek")
|
||||||
|
}
|
||||||
|
headerSize := int(binary.LittleEndian.Uint32(dib[0:4]))
|
||||||
|
width := int(int32(binary.LittleEndian.Uint32(dib[4:8])))
|
||||||
|
height := int(int32(binary.LittleEndian.Uint32(dib[8:12]))) / 2 // tinggi DIB = XOR + AND mask
|
||||||
|
bitCount := int(binary.LittleEndian.Uint16(dib[14:16]))
|
||||||
|
compression := int(binary.LittleEndian.Uint32(dib[16:20]))
|
||||||
|
|
||||||
|
if compression != 0 {
|
||||||
|
return nil, errors.New("entri ICO terkompresi tidak didukung")
|
||||||
|
}
|
||||||
|
if width <= 0 || height <= 0 || width > 512 || height > 512 {
|
||||||
|
return nil, errors.New("ukuran entri ICO tidak wajar")
|
||||||
|
}
|
||||||
|
if bitCount != 24 && bitCount != 32 {
|
||||||
|
return nil, errors.New("hanya ICO 24/32-bit yang didukung")
|
||||||
|
}
|
||||||
|
|
||||||
|
stride := (width*bitCount/8 + 3) & ^3
|
||||||
|
start := headerSize
|
||||||
|
if start+stride*height > len(dib) {
|
||||||
|
return nil, errors.New("data piksel ICO terpotong")
|
||||||
|
}
|
||||||
|
|
||||||
|
img := image.NewNRGBA(image.Rect(0, 0, width, height))
|
||||||
|
allTransparent := bitCount == 32
|
||||||
|
for y := 0; y < height; y++ {
|
||||||
|
row := dib[start+(height-1-y)*stride:] // DIB disimpan dari bawah ke atas
|
||||||
|
for x := 0; x < width; x++ {
|
||||||
|
px := row[x*bitCount/8:]
|
||||||
|
var alpha uint8 = 0xFF
|
||||||
|
if bitCount == 32 {
|
||||||
|
alpha = px[3]
|
||||||
|
if alpha != 0 {
|
||||||
|
allTransparent = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img.SetNRGBA(x, y, color.NRGBA{R: px[2], G: px[1], B: px[0], A: alpha})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sebagian ICO 32-bit tidak mengisi kanal alpha sama sekali
|
||||||
|
if allTransparent {
|
||||||
|
for i := 3; i < len(img.Pix); i += 4 {
|
||||||
|
img.Pix[i] = 0xFF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return img, nil
|
||||||
|
}
|
||||||
@@ -86,9 +86,9 @@ func ToPNG(data []byte) ([]byte, error) {
|
|||||||
return encodePNG(img)
|
return encodePNG(img)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ico bisa berisi PNG langsung
|
// ico: bisa berisi PNG langsung atau gambar BMP di dalamnya
|
||||||
if png := largestPNGFromICO(data); png != nil {
|
if img, err := decodeICO(data); err == nil {
|
||||||
return png, nil
|
return encodePNG(img)
|
||||||
}
|
}
|
||||||
return nil, errors.New("format ikon tidak didukung")
|
return nil, errors.New("format ikon tidak didukung")
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-5
@@ -161,7 +161,9 @@ func queryVariants(query string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// FromURL mengunduh ikon dari URL dan menebak ekstensinya dari isi file.
|
// FromURL mengunduh ikon dari URL. Hasilnya selalu PNG: format apa pun
|
||||||
|
// (svg, webp, gif, ico, jpeg) dikonversi dulu supaya desktop environment
|
||||||
|
// tidak perlu mendukung banyak format.
|
||||||
func FromURL(rawURL string) ([]byte, string, error) {
|
func FromURL(rawURL string) ([]byte, string, error) {
|
||||||
data, err := get(rawURL)
|
data, err := get(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -172,11 +174,65 @@ func FromURL(rawURL string) ([]byte, string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
ext := sniffExt(data)
|
converted, err := ToPNG(data)
|
||||||
if ext == "" {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("unsupported icon format at %s", rawURL)
|
return nil, "", fmt.Errorf("%s: %w", rawURL, err)
|
||||||
}
|
}
|
||||||
return data, ext, nil
|
return converted, ".png", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageExts adalah ekstensi gambar yang bisa dipakai langsung sebagai ikon.
|
||||||
|
var imageExts = []string{".png", ".svg", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".ico", ".xpm"}
|
||||||
|
|
||||||
|
// IsImageURL benar kalau teks yang diketik user berupa URL gambar.
|
||||||
|
func IsImageURL(text string) bool {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if !strings.Contains(text, "://") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(text)
|
||||||
|
for _, ext := range imageExts {
|
||||||
|
if strings.HasSuffix(strings.SplitN(lower, "?", 2)[0], ext) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain mengembalikan domain kalau user mengetik alamat situs (mis. "mikrotik.com"),
|
||||||
|
// bukan nama aplikasi biasa.
|
||||||
|
func Domain(text string) string {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" || strings.ContainsAny(text, " \t") || IsImageURL(text) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// nama file gambar (mis. "lazyrun.png") bukan domain
|
||||||
|
lower := strings.ToLower(strings.SplitN(text, "?", 2)[0])
|
||||||
|
for _, ext := range imageExts {
|
||||||
|
if strings.HasSuffix(lower, ext) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
host := text
|
||||||
|
if strings.Contains(text, "://") {
|
||||||
|
if u, err := url.Parse(text); err == nil {
|
||||||
|
host = u.Hostname()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.Trim(host, "."), ".")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
tld := parts[len(parts)-1]
|
||||||
|
if len(tld) < 2 || len(tld) > 24 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, r := range strings.Join(parts, "") {
|
||||||
|
if !(r == '-' || r == '.' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
// slug menyamakan nama untuk perbandingan (huruf kecil, tanpa pemisah).
|
// slug menyamakan nama untuk perbandingan (huruf kecil, tanpa pemisah).
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package icon
|
package icon
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -119,17 +120,94 @@ func TestFindPicksMatchingName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFromURLSniffsFormat(t *testing.T) {
|
func TestFromURLAlwaysReturnsPNG(t *testing.T) {
|
||||||
srv := fakeIconify(t)
|
srv := fakeIconify(t)
|
||||||
data, ext, err := FromURL(srv.URL + "/logos/whatsapp-icon.svg")
|
data, ext, err := FromURL(srv.URL + "/logos/whatsapp-icon.svg")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if ext != ".svg" || len(data) == 0 {
|
// SVG dari internet dikonversi ke PNG supaya seragam
|
||||||
|
if ext != ".png" || len(data) == 0 {
|
||||||
t.Errorf("FromURL = ext %q, %d byte", ext, len(data))
|
t.Errorf("FromURL = ext %q, %d byte", ext, len(data))
|
||||||
}
|
}
|
||||||
|
if !bytes.HasPrefix(data, pngMagic) {
|
||||||
|
t.Errorf("hasil FromURL bukan PNG: %q", data[:min(8, len(data))])
|
||||||
|
}
|
||||||
|
|
||||||
if _, _, err := FromURL(srv.URL + "/tidak-ada"); err == nil {
|
if _, _, err := FromURL(srv.URL + "/tidak-ada"); err == nil {
|
||||||
t.Error("URL 404 harus error")
|
t.Error("URL 404 harus error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsImageURLAndDomain(t *testing.T) {
|
||||||
|
images := []string{"https://contoh.com/ikon.png", "http://a.b/logo.svg", "https://x.id/i.JPEG?v=2"}
|
||||||
|
for _, u := range images {
|
||||||
|
if !IsImageURL(u) {
|
||||||
|
t.Errorf("IsImageURL(%q) = false", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if IsImageURL("https://contoh.com") || IsImageURL("winbox") {
|
||||||
|
t.Error("URL halaman/nama biasa bukan gambar")
|
||||||
|
}
|
||||||
|
|
||||||
|
domains := map[string]string{
|
||||||
|
"mikrotik.com": "mikrotik.com",
|
||||||
|
" web.whatsapp.com ": "web.whatsapp.com",
|
||||||
|
"https://mikrotik.com/": "mikrotik.com",
|
||||||
|
"winbox": "",
|
||||||
|
"WhatsApp Web": "",
|
||||||
|
"my-app v1.2": "",
|
||||||
|
"lazyrun.png": "",
|
||||||
|
"file:///home/x/icon.png": "",
|
||||||
|
}
|
||||||
|
for in, want := range domains {
|
||||||
|
if got := Domain(in); got != want {
|
||||||
|
t.Errorf("Domain(%q) = %q, mau %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSiteIconsListsSiteAndService(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/":
|
||||||
|
_, _ = w.Write([]byte(`<html><head>` +
|
||||||
|
`<link rel="manifest" href="/manifest.json">` +
|
||||||
|
`<link rel="apple-touch-icon" sizes="180x180" href="/touch.png">` +
|
||||||
|
`<link rel="shortcut icon" href="/favicon.ico">` +
|
||||||
|
`</head></html>`))
|
||||||
|
case "/manifest.json":
|
||||||
|
_, _ = w.Write([]byte(`{"icons":[{"src":"/big-192.png","sizes":"192x192"},{"src":"/big-512.png","sizes":"512x512"}]}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
list, err := SiteIcons(srv.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(list) < 3 {
|
||||||
|
t.Fatalf("ikon situs = %d, mau minimal 3: %+v", len(list), list)
|
||||||
|
}
|
||||||
|
// ikon manifest terbesar harus di depan, lalu ikon halaman, lalu layanan favicon
|
||||||
|
if list[0].URL != srv.URL+"/big-512.png" {
|
||||||
|
t.Errorf("kandidat pertama = %+s", list[0])
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, r := range list {
|
||||||
|
if strings.HasPrefix(r.URL, "https://www.google.com/s2/favicons") {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("layanan favicon tidak disertakan sebagai pilihan")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSiteIconsNeedsValidDomain(t *testing.T) {
|
||||||
|
if _, err := SiteIcons(""); err == nil {
|
||||||
|
t.Error("alamat kosong harus error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package icon
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
"image/png"
|
"image/png"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -80,3 +81,67 @@ func TestRasterizeColorsCurrentColorIcons(t *testing.T) {
|
|||||||
t.Errorf("warna pengganti bukan brand indigo: r=%d g=%d b=%d", r>>8, g>>8, b>>8)
|
t.Errorf("warna pengganti bukan brand indigo: r=%d g=%d b=%d", r>>8, g>>8, b>>8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ico32bpp membuat ICO berisi gambar BMP 32-bit (format favicon lama).
|
||||||
|
func ico32bpp(width int, pixel func(x, y int) (byte, byte, byte, byte)) []byte {
|
||||||
|
stride := (width*4 + 3) & ^3
|
||||||
|
dib := make([]byte, 40+stride*width)
|
||||||
|
binary.LittleEndian.PutUint32(dib[0:], 40)
|
||||||
|
binary.LittleEndian.PutUint32(dib[4:], uint32(width))
|
||||||
|
binary.LittleEndian.PutUint32(dib[8:], uint32(width*2)) // XOR + AND
|
||||||
|
binary.LittleEndian.PutUint16(dib[12:], 1)
|
||||||
|
binary.LittleEndian.PutUint16(dib[14:], 32)
|
||||||
|
|
||||||
|
for y := 0; y < width; y++ {
|
||||||
|
row := dib[40+(width-1-y)*stride:]
|
||||||
|
for x := 0; x < width; x++ {
|
||||||
|
b, g, r, a := pixel(x, y)
|
||||||
|
row[x*4], row[x*4+1], row[x*4+2], row[x*4+3] = b, g, r, a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ico := make([]byte, 22+len(dib))
|
||||||
|
ico[2], ico[3] = 1, 0
|
||||||
|
ico[4], ico[5] = 1, 0
|
||||||
|
put32 := func(off, v int) {
|
||||||
|
binary.LittleEndian.PutUint32(ico[off:], uint32(v))
|
||||||
|
}
|
||||||
|
ico[6] = byte(width) // lebar (0 = 256)
|
||||||
|
ico[7] = byte(width)
|
||||||
|
put32(14, len(dib))
|
||||||
|
put32(18, 22)
|
||||||
|
copy(ico[22:], dib)
|
||||||
|
return ico
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToPNGConvertsBMPIcon(t *testing.T) {
|
||||||
|
ico := ico32bpp(16, func(x, y int) (byte, byte, byte, byte) {
|
||||||
|
return 0x20, 0x80, 0xE0, 0xFF // BGR + alpha
|
||||||
|
})
|
||||||
|
|
||||||
|
out, err := ToPNG(ico)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ico BMP harus bisa dikonversi: %v", err)
|
||||||
|
}
|
||||||
|
img, err := png.Decode(bytes.NewReader(out))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r, g, b, a := img.At(8, 8).RGBA()
|
||||||
|
if a == 0 {
|
||||||
|
t.Fatal("piksel ICO BMP transparan, seharusnya opaque")
|
||||||
|
}
|
||||||
|
// warna BGR 0x20,0x80,0xE0 = RGB (0xE0,0x80,0x20)
|
||||||
|
if r>>8 != 0xE0 || g>>8 != 0x80 || b>>8 != 0x20 {
|
||||||
|
t.Errorf("warna salah: r=%#x g=%#x b=%#x", r>>8, g>>8, b>>8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeICORejectsGarbage(t *testing.T) {
|
||||||
|
if _, err := decodeICO([]byte("bukan ico")); err == nil {
|
||||||
|
t.Error("data bukan ICO harus error")
|
||||||
|
}
|
||||||
|
if _, err := decodeICO(icoWithPNG([]byte("bukan png"))); err == nil {
|
||||||
|
t.Error("entry PNG rusak harus error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) {
|
|||||||
entry.SetPlaceHolder("whatsapp")
|
entry.SetPlaceHolder("whatsapp")
|
||||||
|
|
||||||
status := muted(T("search_hint"))
|
status := muted(T("search_hint"))
|
||||||
|
entry.SetPlaceHolder("whatsapp / mikrotik.com")
|
||||||
grid := container.NewGridWithColumns(6)
|
grid := container.NewGridWithColumns(6)
|
||||||
results := container.NewVScroll(container.NewPadded(grid))
|
results := container.NewVScroll(container.NewPadded(grid))
|
||||||
slots := make(chan struct{}, 3)
|
slots := make(chan struct{}, 3)
|
||||||
@@ -69,7 +70,7 @@ func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) {
|
|||||||
status.SetText(T("searching"))
|
status.SetText(T("searching"))
|
||||||
grid.RemoveAll()
|
grid.RemoveAll()
|
||||||
go func() {
|
go func() {
|
||||||
list, err := icon.SearchBest(entry.Text, 24)
|
list, err := lookupIcons(entry.Text)
|
||||||
fyne.Do(func() {
|
fyne.Do(func() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
status.SetText(T("search_failed"))
|
status.SetText(T("search_failed"))
|
||||||
@@ -102,6 +103,22 @@ func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) {
|
|||||||
search()
|
search()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lookupIcons memilih sumber sesuai yang diketik user:
|
||||||
|
// URL gambar → dipakai langsung, domain situs → ikon resmi situs itu,
|
||||||
|
// selain itu → pencarian nama di Iconify.
|
||||||
|
func lookupIcons(query string) ([]icon.Result, error) {
|
||||||
|
query = strings.TrimSpace(query)
|
||||||
|
if icon.IsImageURL(query) {
|
||||||
|
return []icon.Result{{Name: "URL ikon", URL: query}}, nil
|
||||||
|
}
|
||||||
|
if domain := icon.Domain(query); domain != "" {
|
||||||
|
if list, err := icon.SiteIcons(domain); err == nil && len(list) > 0 {
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return icon.SearchBest(query, 24)
|
||||||
|
}
|
||||||
|
|
||||||
// iconChoice adalah satu ikon hasil pencarian. Gambarnya diunduh di latar belakang
|
// iconChoice adalah satu ikon hasil pencarian. Gambarnya diunduh di latar belakang
|
||||||
// dan digambar sebagai PNG: SVG dari internet kadang tidak bisa digambar sehingga
|
// dan digambar sebagai PNG: SVG dari internet kadang tidak bisa digambar sehingga
|
||||||
// thumbnail tidak muncul atau proses menggambar berhenti.
|
// thumbnail tidak muncul atau proses menggambar berhenti.
|
||||||
|
|||||||
Reference in new issue
Block a user