mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-26 17:09:14 +02:00
Compare commits
5 Commits
agent/agen
...
fix/self-h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be8a2040f5 | ||
|
|
6bbe059055 | ||
|
|
cf70860a0b | ||
|
|
9f350e312d | ||
|
|
817e69a9eb |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -12,10 +12,17 @@ build
|
||||
bin
|
||||
dist-electron
|
||||
*.tsbuildinfo
|
||||
# ...except electron-builder's source resources dir, which holds tracked
|
||||
# config files (entitlements, icons) — not build output.
|
||||
!apps/desktop/build/
|
||||
!apps/desktop/build/**
|
||||
|
||||
# env
|
||||
.env*
|
||||
!.env.example
|
||||
# Desktop production config is public (backend URL, etc.) — track it so
|
||||
# `pnpm package` produces a release-ready build without extra setup.
|
||||
!apps/desktop/.env.production
|
||||
|
||||
# test coverage
|
||||
coverage
|
||||
|
||||
12
apps/desktop/.env.production
Normal file
12
apps/desktop/.env.production
Normal file
@@ -0,0 +1,12 @@
|
||||
# Production environment for `pnpm package` / `pnpm build`.
|
||||
# electron-vite (Vite under the hood) reads this automatically in
|
||||
# production mode and inlines the values into the renderer bundle via
|
||||
# import.meta.env.VITE_*. These are public URLs, not secrets.
|
||||
|
||||
# Backend API + websocket the desktop app talks to.
|
||||
VITE_API_URL=https://api.multica.ai
|
||||
VITE_WS_URL=wss://api.multica.ai/ws
|
||||
|
||||
# Public web app URL — used to build shareable links like "Copy link to
|
||||
# issue" that users paste into Slack / messages. See platform/navigation.tsx.
|
||||
VITE_APP_URL=https://multica.ai
|
||||
24
apps/desktop/build/entitlements.mac.plist
Normal file
24
apps/desktop/build/entitlements.mac.plist
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Electron / V8 need JIT and unsigned executable memory under the
|
||||
hardened runtime. -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<!-- Required so the app can spawn the bundled `multica` Go binary and
|
||||
any other child processes (e.g. agent CLIs) without Gatekeeper
|
||||
blocking exec. -->
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<!-- Network client — the daemon talks to the backend + GitHub releases. -->
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -19,10 +19,16 @@ mac:
|
||||
target:
|
||||
- dmg
|
||||
- zip
|
||||
artifactName: ${name}-${version}-${arch}.${ext}
|
||||
notarize: false
|
||||
# Hardcoded name avoids the `@multica/desktop-*` subdirectory that
|
||||
# `${name}` produces for scoped package names.
|
||||
artifactName: multica-desktop-${version}-${arch}.${ext}
|
||||
# Notarize via notarytool. Requires APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD
|
||||
# + APPLE_TEAM_ID env vars at package time. Non-mac contributors are
|
||||
# unaffected because `pnpm package` already requires the Developer ID
|
||||
# signing cert — notarization is a strict superset.
|
||||
notarize: true
|
||||
dmg:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
artifactName: multica-desktop-${version}-${arch}.${ext}
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
|
||||
"preview": "electron-vite preview",
|
||||
"package": "pnpm run bundle-cli && electron-builder",
|
||||
"package": "node scripts/package.mjs",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"postinstall": "electron-builder install-app-deps"
|
||||
|
||||
122
apps/desktop/scripts/package.mjs
Normal file
122
apps/desktop/scripts/package.mjs
Normal file
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env node
|
||||
// Wrapper around `electron-builder` that keeps the Desktop version in
|
||||
// lockstep with the CLI. Both are derived from `git describe --tags
|
||||
// --always --dirty` — the same source GoReleaser reads for the CLI
|
||||
// binary via the `main.version` ldflag — so a single `vX.Y.Z` tag push
|
||||
// produces matching CLI and Desktop versions.
|
||||
//
|
||||
// Runs the existing bundle-cli.mjs first (so the Go binary is compiled
|
||||
// and copied into resources/bin/), then invokes electron-builder with
|
||||
// `-c.extraMetadata.version=<derived>` so the override applies at build
|
||||
// time without mutating the tracked package.json.
|
||||
//
|
||||
// Extra CLI args after `pnpm package --` are forwarded to electron-builder
|
||||
// unchanged (e.g. `--mac --arm64`).
|
||||
//
|
||||
// The `normalizeGitVersion` helper is exported so tests can cover the
|
||||
// version-derivation logic without shelling out.
|
||||
|
||||
import { execFileSync, spawnSync, execSync } from "node:child_process";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const desktopRoot = resolve(here, "..");
|
||||
|
||||
function sh(cmd) {
|
||||
try {
|
||||
return execSync(cmd, { encoding: "utf-8" }).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure transformation from the `git describe --tags --always --dirty`
|
||||
* output to the value we feed into electron-builder's extraMetadata.version.
|
||||
*
|
||||
* - empty input → null (caller should fall back)
|
||||
* - "v0.1.36" → "0.1.36"
|
||||
* - "v0.1.35-14-gf1415e96" → "0.1.35-14-gf1415e96" (semver prerelease)
|
||||
* - "v0.1.35-…-dirty" → same, dirty suffix preserved
|
||||
* - "f1415e96" (no tag) → "0.0.0-f1415e96" (fallback)
|
||||
*
|
||||
* Leading `v` is stripped so the result is valid semver for package.json.
|
||||
*/
|
||||
export function normalizeGitVersion(raw) {
|
||||
if (!raw) return null;
|
||||
const stripped = raw.replace(/^v/, "");
|
||||
if (!/^\d/.test(stripped)) {
|
||||
// No reachable tag — `git describe` fell back to just the commit hash.
|
||||
return `0.0.0-${stripped}`;
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
|
||||
function deriveVersion() {
|
||||
return normalizeGitVersion(sh("git describe --tags --always --dirty"));
|
||||
}
|
||||
|
||||
function main() {
|
||||
// Step 1: build + bundle the Go CLI via the existing script.
|
||||
execFileSync("node", [resolve(here, "bundle-cli.mjs")], {
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
});
|
||||
|
||||
// Step 2: derive the version that should be written into the app.
|
||||
const version = deriveVersion();
|
||||
if (version) {
|
||||
console.log(`[package] Desktop version → ${version} (from git describe)`);
|
||||
} else {
|
||||
console.warn(
|
||||
"[package] could not derive version from git; falling back to package.json",
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: assemble electron-builder args.
|
||||
const passthrough = process.argv.slice(2);
|
||||
const builderArgs = [];
|
||||
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);
|
||||
|
||||
// Step 4: gracefully degrade for local dev builds. electron-builder.yml
|
||||
// sets `notarize: true` so real releases notarize in-build (keeping the
|
||||
// stapled .app consistent with latest-mac.yml's SHA512). But a mac dev
|
||||
// who just wants to smoke-test a local package doesn't have Apple
|
||||
// credentials, and would otherwise hit a hard failure at the notarize
|
||||
// step. Detect the missing env and flip notarize off for this run only.
|
||||
if (!process.env.APPLE_TEAM_ID) {
|
||||
console.warn(
|
||||
"[package] APPLE_TEAM_ID not set — skipping notarization (local dev build). " +
|
||||
"Set APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID for a release build.",
|
||||
);
|
||||
builderArgs.push("-c.mac.notarize=false");
|
||||
}
|
||||
|
||||
builderArgs.push(...passthrough);
|
||||
|
||||
// Step 5: invoke electron-builder. pnpm puts node_modules/.bin on PATH
|
||||
// for the script run, so spawnSync finds the binary without needing a
|
||||
// shell wrapper (avoids any risk of argv interpolation).
|
||||
const result = spawnSync("electron-builder", builderArgs, {
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(
|
||||
"[package] failed to spawn electron-builder:",
|
||||
result.error.message,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
// Only run when invoked as a CLI, not when imported by a test file.
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
) {
|
||||
main();
|
||||
}
|
||||
39
apps/desktop/scripts/package.test.mjs
Normal file
39
apps/desktop/scripts/package.test.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { normalizeGitVersion } from "./package.mjs";
|
||||
|
||||
describe("normalizeGitVersion", () => {
|
||||
it("returns null for empty / nullish input", () => {
|
||||
expect(normalizeGitVersion("")).toBe(null);
|
||||
expect(normalizeGitVersion(null)).toBe(null);
|
||||
expect(normalizeGitVersion(undefined)).toBe(null);
|
||||
});
|
||||
|
||||
it("strips the leading v on a clean tag", () => {
|
||||
expect(normalizeGitVersion("v0.1.36")).toBe("0.1.36");
|
||||
expect(normalizeGitVersion("v1.0.0")).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("preserves the prerelease suffix between tags", () => {
|
||||
expect(normalizeGitVersion("v0.1.35-14-gf1415e96")).toBe(
|
||||
"0.1.35-14-gf1415e96",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the dirty suffix on a modified worktree", () => {
|
||||
expect(normalizeGitVersion("v0.1.35-14-gf1415e96-dirty")).toBe(
|
||||
"0.1.35-14-gf1415e96-dirty",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles v-prefixed prerelease tags", () => {
|
||||
expect(normalizeGitVersion("v1.0.0-alpha")).toBe("1.0.0-alpha");
|
||||
expect(normalizeGitVersion("v1.0.0-rc.2")).toBe("1.0.0-rc.2");
|
||||
});
|
||||
|
||||
it("falls back to 0.0.0-<hash> when no tags are reachable", () => {
|
||||
// `git describe --tags --always` returns just the short commit hash
|
||||
// when there are no tags in the history at all.
|
||||
expect(normalizeGitVersion("f1415e96")).toBe("0.0.0-f1415e96");
|
||||
expect(normalizeGitVersion("abc1234")).toBe("0.0.0-abc1234");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
|
||||
|
||||
// Public web app URL — injected at build time via .env.production. Falls
|
||||
// back to the production host for dev builds so "Copy link" yields a URL
|
||||
// that actually points somewhere a teammate can open.
|
||||
const APP_URL = import.meta.env.VITE_APP_URL || "https://multica.ai";
|
||||
|
||||
/**
|
||||
* Root-level navigation provider for components outside the per-tab RouterProviders
|
||||
* (sidebar, search dialog, modals, etc.).
|
||||
@@ -64,7 +69,7 @@ export function DesktopNavigationProvider({
|
||||
const tabId = store.openTab(path, title ?? path, icon);
|
||||
store.setActiveTab(tabId);
|
||||
},
|
||||
getShareableUrl: (path: string) => `https://www.multica.ai${path}`,
|
||||
getShareableUrl: (path: string) => `${APP_URL}${path}`,
|
||||
}),
|
||||
[pathname],
|
||||
);
|
||||
@@ -107,7 +112,7 @@ export function TabNavigationProvider({
|
||||
const newTabId = store.openTab(path, title ?? path, icon);
|
||||
store.setActiveTab(newTabId);
|
||||
},
|
||||
getShareableUrl: (path: string) => `https://www.multica.ai${path}`,
|
||||
getShareableUrl: (path: string) => `${APP_URL}${path}`,
|
||||
}),
|
||||
[router, location],
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config";
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
include: ["src/**/*.test.ts"],
|
||||
include: ["src/**/*.test.ts", "scripts/**/*.test.mjs"],
|
||||
environment: "node",
|
||||
passWithNoTests: true,
|
||||
},
|
||||
|
||||
@@ -359,6 +359,7 @@ function ListDropdown({ editor, onOpenChange, isBullet, isOrdered }: { editor: E
|
||||
function EditorBubbleMenu({ editor }: { editor: Editor }) {
|
||||
const [mode, setMode] = useState<"toolbar" | "link-edit">("toolbar");
|
||||
const [scrollTarget, setScrollTarget] = useState<HTMLElement | Window>(window);
|
||||
const menuElRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Precise subscription to formatting state — only re-renders when these
|
||||
// values actually change, replacing direct editor.isActive() calls that
|
||||
@@ -456,6 +457,7 @@ function EditorBubbleMenu({ editor }: { editor: Editor }) {
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
ref={menuElRef}
|
||||
editor={editor}
|
||||
shouldShow={shouldShowBubbleMenu}
|
||||
updateDelay={0}
|
||||
@@ -471,6 +473,17 @@ function EditorBubbleMenu({ editor }: { editor: Editor }) {
|
||||
shift: { padding: 8 },
|
||||
hide: true,
|
||||
scrollTarget,
|
||||
// Tiptap's React wrapper initialises the menu element with
|
||||
// position:absolute, but computePosition (called right after
|
||||
// show()) needs position:fixed so that getOffsetParent returns
|
||||
// the viewport instead of a positioned ancestor. Without this,
|
||||
// the first positioning computes coordinates relative to the
|
||||
// wrong containing block and the menu flies off-screen.
|
||||
onShow: () => {
|
||||
if (menuElRef.current) {
|
||||
menuElRef.current.style.position = "fixed";
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
{mode === "link-edit" ? (
|
||||
|
||||
@@ -98,21 +98,12 @@ func runAuthLoginBrowser(cmd *cobra.Command) error {
|
||||
serverURL := resolveServerURL(cmd)
|
||||
appURL := resolveAppURL(cmd)
|
||||
|
||||
// Determine the callback host from the configured app URL.
|
||||
// For self-hosted setups where the browser is on a different machine
|
||||
// (e.g. Multica running on a LAN server), use the server's private IP
|
||||
// so the browser can reach the CLI's local HTTP server.
|
||||
// For production (public hostnames like multica.ai), keep localhost —
|
||||
// the browser and CLI are on the same machine.
|
||||
// The callback always targets localhost because the browser is opened on
|
||||
// the same machine as the CLI (via openBrowser). Even when the Multica
|
||||
// server is on a remote LAN host, the browser-side redirect must reach
|
||||
// the CLI's local HTTP server, not the remote server.
|
||||
callbackHost := "localhost"
|
||||
bindAddr := "127.0.0.1"
|
||||
if parsed, err := url.Parse(appURL); err == nil {
|
||||
h := parsed.Hostname()
|
||||
if ip := net.ParseIP(h); ip != nil && ip.IsPrivate() {
|
||||
callbackHost = h
|
||||
bindAddr = "0.0.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
// Start a local HTTP server on a random port to receive the callback.
|
||||
listener, err := net.Listen("tcp", bindAddr+":0")
|
||||
|
||||
Reference in New Issue
Block a user