Compare commits

..

1 Commits

Author SHA1 Message Date
Jiang Bohan
645afd739b fix(desktop): replace Electron placeholder icons with Multica asterisk for Windows + Linux
Both `apps/desktop/build/icon.ico` (Windows installer + Multica.exe) and
`apps/desktop/build/icon.png` (Linux deb/rpm/AppImage) were the default
electron-vite scaffold "atom" placeholder. They were never updated when
the macOS `icon.icns` was switched to the Multica asterisk in #1074, and
have shipped as-is in every v0.2.x release including v0.2.26 — closes
GitHub #2195.

Source: 1024×1024 PNG extracted from the existing build/icon.icns
(icon_512x512@2x), so all three platforms now share the same artwork.

- icon.ico: BMP frames at 16/24/32/48/64/128 + PNG-compressed 256×256.
  Matches electron-builder's "≥256×256" requirement and the BMP-then-PNG
  format mix Windows Explorer / NSIS render best across Win10/11.
- icon.png: 1024×1024 RGBA, replacing the previous 512×512 placeholder.

No electron-builder.yml change needed — buildResources: build picks
both files up automatically.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-08 11:08:48 +08:00
5 changed files with 13 additions and 161 deletions

View File

@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "@multica/core/api";
import { AppSidebar } from "./app-sidebar";
const { detail, deletePin, pins, nav } = vi.hoisted(() => ({
const { detail, deletePin, pins } = vi.hoisted(() => ({
detail: { current: { isPending: false, isError: false, data: null as unknown, error: null as unknown } },
deletePin: vi.fn(),
pins: {
@@ -19,7 +19,6 @@ const { detail, deletePin, pins, nav } = vi.hoisted(() => ({
},
],
},
nav: { pathname: "/acme/issues" },
}));
vi.mock("@dnd-kit/core", () => ({
@@ -44,9 +43,7 @@ vi.mock("@multica/ui/components/ui/sidebar", () => ({
SidebarGroupLabel: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SidebarHeader: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SidebarMenu: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SidebarMenuButton: ({ children, isActive }: { children: React.ReactNode; isActive?: boolean }) => (
<button type="button" data-active={isActive ? "true" : undefined}>{children}</button>
),
SidebarMenuButton: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
SidebarMenuItem: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SidebarRail: () => null,
}));
@@ -74,7 +71,7 @@ vi.mock("../auth", () => ({ useLogout: () => vi.fn() }));
vi.mock("../issues/components/status-icon", () => ({ StatusIcon: () => <span /> }));
vi.mock("../navigation", () => ({
AppLink: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
useNavigation: () => ({ pathname: nav.pathname, push: vi.fn() }),
useNavigation: () => ({ pathname: "/acme/issues", push: vi.fn() }),
}));
vi.mock("../projects/components/project-icon", () => ({ ProjectIcon: () => <span /> }));
vi.mock("../workspace/workspace-avatar", () => ({ WorkspaceAvatar: () => <span /> }));
@@ -130,7 +127,6 @@ describe("PinRow", () => {
beforeEach(() => {
deletePin.mockReset();
detail.current = { isPending: false, isError: false, data: null, error: null };
nav.pathname = "/acme/issues";
});
it("unpins missing details", async () => {
@@ -150,24 +146,4 @@ describe("PinRow", () => {
render(<AppSidebar />);
expect(await screen.findByText("MUL-123 Keep this pin")).toBeInTheDocument();
});
it("does not activate parent tab when on a pinned item's path", async () => {
detail.current = { isPending: false, isError: false, data: { identifier: "MUL-123", title: "Keep this pin", status: "todo" }, error: null };
nav.pathname = "/acme/issues/issue-1";
const { container } = render(<AppSidebar />);
const issuesTab = container.querySelector(".lucide-list-todo")?.closest("button");
expect(issuesTab).not.toBeNull();
expect(issuesTab).not.toHaveAttribute("data-active");
const pinButton = screen.getByText("MUL-123 Keep this pin").closest("button");
expect(pinButton).toHaveAttribute("data-active", "true");
});
it("still activates parent tab on a non-pinned item path", async () => {
detail.current = { isPending: false, isError: false, data: { identifier: "MUL-123", title: "Keep this pin", status: "todo" }, error: null };
nav.pathname = "/acme/issues/issue-999";
const { container } = render(<AppSidebar />);
const issuesTab = container.querySelector(".lucide-list-todo")?.closest("button");
expect(issuesTab).not.toBeNull();
expect(issuesTab).toHaveAttribute("data-active", "true");
});
});

View File

@@ -365,14 +365,6 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
// write (our own optimistic update, or a WS refetch) cannot reorder the
// DOM under dnd-kit while its drop animation is still interpolating.
const [localPinned, setLocalPinned] = useState<PinnedItem[]>(pinnedItems);
// When the current path matches a pinned item exactly, the pin owns the
// sidebar highlight — suppress the workspace-group tab (Issues / Projects)
// so two rows don't appear active at once. Non-pinned detail pages still
// light up their parent tab via isNavActive's prefix match.
const pathMatchesPin = localPinned.some((pin) => {
const pinHref = pin.item_type === "issue" ? p.issueDetail(pin.item_id) : p.projectDetail(pin.item_id);
return pathname === pinHref;
});
const isDraggingRef = useRef(false);
useEffect(() => {
if (!isDraggingRef.current) {
@@ -676,7 +668,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
<SidebarMenu className="gap-0.5">
{workspaceNav.map((item) => {
const href = p[item.key]();
const isActive = !pathMatchesPin && isNavActive(pathname, href);
const isActive = isNavActive(pathname, href);
return (
<SidebarMenuItem key={item.key}>
<SidebarMenuButton

View File

@@ -122,23 +122,6 @@ func FetchLatestRelease() (*GitHubRelease, error) {
return &release, nil
}
// knownBrewPrefixes lists the install roots Homebrew uses on each platform.
// Order is irrelevant — the prefixes do not nest.
var knownBrewPrefixes = []string{"/opt/homebrew", "/usr/local", "/home/linuxbrew/.linuxbrew"}
// MatchKnownBrewPrefix returns the Homebrew prefix whose Cellar contains path,
// or "" if path is not under a known Cellar. It is the offline equivalent of
// `brew --prefix`: callers reach for it when `brew --prefix` is unavailable
// (brew not on PATH) but the binary's path still betrays its install root.
func MatchKnownBrewPrefix(path string) string {
for _, prefix := range knownBrewPrefixes {
if strings.HasPrefix(path, prefix+"/Cellar/") {
return prefix
}
}
return ""
}
// IsBrewInstall checks whether the running multica binary was installed via Homebrew.
func IsBrewInstall() bool {
exePath, err := os.Executable()
@@ -155,7 +138,12 @@ func IsBrewInstall() bool {
return true
}
return MatchKnownBrewPrefix(resolved) != ""
for _, prefix := range []string{"/opt/homebrew", "/usr/local", "/home/linuxbrew/.linuxbrew"} {
if strings.HasPrefix(resolved, prefix+"/Cellar/") {
return true
}
}
return false
}
// GetBrewPrefix returns the Homebrew prefix by running `brew --prefix`, or empty string.

View File

@@ -26,12 +26,6 @@ import (
// server refresh.
var ErrRepoNotConfigured = errors.New("repo is not configured for this workspace")
var (
isBrewInstall = cli.IsBrewInstall
getBrewPrefix = cli.GetBrewPrefix
matchKnownBrewPrefix = cli.MatchKnownBrewPrefix
)
// workspaceState tracks registered runtimes for a single workspace.
//
// allowedRepoURLs covers the workspace-level repo bindings; it gets rebuilt on
@@ -1149,19 +1143,9 @@ func (d *Daemon) triggerRestart() {
d.logger.Error("could not resolve executable path for restart", "error", err)
return
}
// On Linux, os.Executable() reads /proc/self/exe, which the kernel resolves
// to the Cellar path. brew cleanup deletes that path after upgrade, so we
// must use the stable <brew-prefix>/bin/multica symlink instead.
if isBrewInstall() {
if brewPrefix := getBrewPrefix(); brewPrefix != "" {
newBin = filepath.Join(brewPrefix, "bin", "multica")
} else if prefix := matchKnownBrewPrefix(newBin); prefix != "" {
newBin = filepath.Join(prefix, "bin", "multica")
} else {
d.logger.Warn("brew install detected but prefix could not be resolved; restart may fail",
"executable", newBin)
}
} else {
// Only resolve symlinks for non-brew installs. Brew uses a symlink that
// points to the latest Cellar version, so we must preserve it.
if !cli.IsBrewInstall() {
if resolved, err := filepath.EvalSymlinks(newBin); err == nil {
newBin = resolved
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
@@ -53,93 +52,6 @@ func TestNormalizeServerBaseURL(t *testing.T) {
}
}
func TestTriggerRestart_BrewLinuxCellarDeleted(t *testing.T) {
originalIsBrewInstall := isBrewInstall
originalGetBrewPrefix := getBrewPrefix
t.Cleanup(func() {
isBrewInstall = originalIsBrewInstall
getBrewPrefix = originalGetBrewPrefix
})
prefix := filepath.Join(t.TempDir(), "home", "linuxbrew", ".linuxbrew")
deletedCellarPath := filepath.Join(prefix, "Cellar", "multica", "0.2.9", "bin", "multica")
isBrewInstall = func() bool { return true }
getBrewPrefix = func() string { return prefix }
d := &Daemon{
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
d.triggerRestart()
want := filepath.Join(prefix, "bin", "multica")
if got := d.RestartBinary(); got != want {
t.Fatalf("restart binary = %q, want %q", got, want)
}
if got := d.RestartBinary(); got == deletedCellarPath {
t.Fatalf("restart binary used deleted Cellar path %q", got)
}
}
// When `brew --prefix` is unavailable but the executable path is under a
// known Cellar root, triggerRestart must recover the prefix from the
// known-prefix list and target <prefix>/bin/multica.
func TestTriggerRestart_BrewPrefixUnavailable_FallsBackToKnownPrefix(t *testing.T) {
originalIsBrewInstall := isBrewInstall
originalGetBrewPrefix := getBrewPrefix
originalMatchKnownBrewPrefix := matchKnownBrewPrefix
t.Cleanup(func() {
isBrewInstall = originalIsBrewInstall
getBrewPrefix = originalGetBrewPrefix
matchKnownBrewPrefix = originalMatchKnownBrewPrefix
})
const knownPrefix = "/home/linuxbrew/.linuxbrew"
isBrewInstall = func() bool { return true }
getBrewPrefix = func() string { return "" }
matchKnownBrewPrefix = func(string) string { return knownPrefix }
d := &Daemon{
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
d.triggerRestart()
want := filepath.Join(knownPrefix, "bin", "multica")
if got := d.RestartBinary(); got != want {
t.Fatalf("restart binary = %q, want %q", got, want)
}
}
// When `brew --prefix` is unavailable AND the executable is not under any
// known Cellar root, triggerRestart logs a warning and keeps the executable
// path (no fabricated <prefix>/bin/multica path).
func TestTriggerRestart_BrewPrefixUnavailable_NoKnownPrefix_KeepsExecutable(t *testing.T) {
originalIsBrewInstall := isBrewInstall
originalGetBrewPrefix := getBrewPrefix
originalMatchKnownBrewPrefix := matchKnownBrewPrefix
t.Cleanup(func() {
isBrewInstall = originalIsBrewInstall
getBrewPrefix = originalGetBrewPrefix
matchKnownBrewPrefix = originalMatchKnownBrewPrefix
})
isBrewInstall = func() bool { return true }
getBrewPrefix = func() string { return "" }
matchKnownBrewPrefix = func(string) string { return "" }
d := &Daemon{
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
d.triggerRestart()
exe, err := os.Executable()
if err != nil {
t.Fatalf("os.Executable: %v", err)
}
if got := d.RestartBinary(); got != exe {
t.Fatalf("restart binary = %q, want unchanged executable %q", got, exe)
}
}
func TestNewTaskSlotSemaphoreReturnsStableSlotIndexes(t *testing.T) {
t.Parallel()