Skip to content
Merged
46 changes: 36 additions & 10 deletions shell/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ use tauri::{
ipc::Channel,
menu::{IsMenuItem, Menu, MenuItem, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
webview::PageLoadEvent,
AppHandle, Emitter, Manager, RunEvent, State, WebviewUrl, WebviewWindowBuilder,
WindowEvent,
};
Expand Down Expand Up @@ -660,21 +661,26 @@ pub fn run() {
kill_sidecar(app_handle);
}
// macOS delivers Reopen when the app is re-activated — clicking its
// notification banner, its Dock icon, etc. Desktop notifications
// can't carry a routable button (the plugin's show() is
// fire-and-forget), so this is how the sign-in nudge's "click here"
// lands somewhere: if we're up but not signed in and nothing's on
// screen, bring up Settings → account.
// notification banner ("Maximal is running"), its Dock icon, etc.
// Desktop notifications can't carry a routable button (the plugin's
// show() is fire-and-forget), so Reopen is how a banner click lands
// somewhere: if nothing's on screen, open Settings. Route to the
// account section when we're up but not signed in (the sign-in
// nudge), otherwise plain Settings.
#[cfg(target_os = "macos")]
RunEvent::Reopen {
has_visible_windows,
..
} => {
if !has_visible_windows
&& app_handle.state::<AppStatus>().get()
if !has_visible_windows {
let section = if app_handle.state::<AppStatus>().get()
== SidecarState::RunningUnauthenticated
{
open_settings_window(app_handle, Some("account"));
{
Some("account")
} else {
None
};
open_settings_window(app_handle, section);
}
}
_ => {}
Expand Down Expand Up @@ -1406,6 +1412,18 @@ fn create_splash(app: &AppHandle) {
.transparent(true)
.always_on_top(true)
.skip_taskbar(true)
// Build hidden and only show once the webview reports the DOM has
// loaded. On Windows/WebView2 the native surface is presented before
// the compositor draws its first frame, so a visible-from-launch
// transparent window shows an empty outline for hundreds of ms–seconds
// before the brand-red `.splash` div pops in. macOS/WKWebView paints in
// lockstep with show, so this fires immediately there — no regression.
.visible(false)
.on_page_load(|window, payload| {
if payload.event() == PageLoadEvent::Finished {
let _ = window.show();
}
})
.center()
.build();
match result {
Expand Down Expand Up @@ -1483,11 +1501,19 @@ fn dismiss_splash(app: &AppHandle) {
/// denial or a dev (`cargo run`) no-op must not matter.
fn fire_startup_notification(app: &AppHandle) {
use tauri_plugin_notification::NotificationExt;
// Where the icon lives — and which way to point — is platform-specific:
// macOS puts it in the top menu bar (↑); Windows puts it in the
// bottom-right system tray (↓). Leave the macOS copy exactly as-is.
let body = if cfg!(target_os = "macos") {
"Look for the Maximal icon in your menu bar ↑"
} else {
"Maximal is running in your system tray ↓"
};
if let Err(err) = app
.notification()
.builder()
.title("Maximal is running")
.body("Look for the Maximal icon in your menu bar ↑")
.body(body)
.show()
{
eprintln!("[shell] startup notification failed: {err}");
Expand Down
54 changes: 53 additions & 1 deletion shell/src/ui/features/apps/AppCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { useState } from "react";

import type { AppEntry } from "../../../proxy/client";
import { Button } from "../../components/Button";
import { ConfirmDialog } from "../../components/ConfirmDialog";
import { Switch } from "../../components/Switch";
import { cx } from "../../components/cx";
import { isWindows } from "../../platform";

import type { MutationResult } from "./useApps";

Expand Down Expand Up @@ -36,6 +38,13 @@ function conflictCopy(app: AppEntry): { title: string; detail: string } | null {
export function AppCard({ app, onToggle, onRescan }: AppCardProps): JSX.Element {
const [copied, setCopied] = useState(false);
const [rescanning, setRescanning] = useState(false);
// Windows-only: disabling Claude Code routing doesn't take effect in an
// already-running session (Claude Code reads its base URL at launch on
// Windows; macOS picks it up live). Warn before disabling so the user
// knows to /exit and relaunch. See issue #178.
const [restartWarnOpen, setRestartWarnOpen] = useState(false);
const [disabling, setDisabling] = useState(false);
const needsWindowsRestartWarning = app.id === "claude-code" && isWindows();

const comingSoon = app.kind === "coming-soon";
const notInstalled = app.status === "not-installed";
Expand Down Expand Up @@ -64,6 +73,23 @@ export function AppCard({ app, onToggle, onRescan }: AppCardProps): JSX.Element
setRescanning(false);
};

// Intercept only the Claude-Code-disable-on-Windows case; everything else
// (enabling, other apps, macOS) toggles straight through.
const handleToggle = (next: boolean): void => {
if (!next && needsWindowsRestartWarning) {
setRestartWarnOpen(true);
return;
}
void onToggle(next);
};

const confirmDisable = async (): Promise<void> => {
setDisabling(true);
await onToggle(false);
setDisabling(false);
setRestartWarnOpen(false);
};

return (
<article
className={cx("app-card", comingSoon && "app-card--soon")}
Expand All @@ -79,7 +105,7 @@ export function AppCard({ app, onToggle, onRescan }: AppCardProps): JSX.Element
<Switch
checked={app.enabled}
disabled={notInstalled}
onCheckedChange={(next) => void onToggle(next)}
onCheckedChange={handleToggle}
label={app.enabled ? "On" : "Off"}
/>
)}
Expand Down Expand Up @@ -158,6 +184,32 @@ export function AppCard({ app, onToggle, onRescan }: AppCardProps): JSX.Element
</span>
</div>
)}

{/* Windows-only heads-up before disabling Claude Code routing: a
running session keeps using the proxy until it's restarted. */}
{needsWindowsRestartWarning && (
<ConfirmDialog
open={restartWarnOpen}
title="Restart Claude Code to finish"
body={
<>
<p>
On Windows, a Claude Code session that's already running keeps
routing through maximal until you restart it.
</p>
<p>
After you disable this, run <code className="mono">/exit</code>{" "}
in Claude Code and start it again for the change to take effect.
</p>
</>
}
confirmLabel="Disable routing"
cancelLabel="Keep on"
busy={disabling}
onConfirm={confirmDisable}
onCancel={() => setRestartWarnOpen(false)}
/>
)}
</article>
);
}
18 changes: 18 additions & 0 deletions shell/src/ui/platform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Best-effort OS detection for the settings webview.
*
* The embedded UI is the SAME bundle on every platform, so anything that
* must differ by OS (e.g. the Windows-only "restart Claude Code" caveat)
* has to branch at runtime. We have no `tauri-plugin-os` registered, and
* the UI can also be opened in a plain browser, so the dependency-free
* signal is the user-agent: WebView2 on Windows reports "Windows NT",
* WKWebView on macOS reports "Macintosh". Prefer the structured
* `userAgentData.platform` when present, fall back to the UA string.
*/
export function isWindows(): boolean {
if (typeof navigator === "undefined") return false;
const uaData = (navigator as { userAgentData?: { platform?: string } })
.userAgentData;
if (uaData?.platform) return /windows/i.test(uaData.platform);
return /windows/i.test(navigator.userAgent);
}
46 changes: 43 additions & 3 deletions site/src/components/GodRays.astro
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
uniform vec2 uRes;
uniform float uTime;
uniform float uIntensity;
uniform float uLightY;
uniform vec3 uColorA;
uniform vec3 uColorB;
uniform vec3 uColorC;
Expand All @@ -51,12 +52,12 @@
float aspect = uRes.x / uRes.y;
vec2 p = vec2((uv.x - 0.5) * aspect + 0.5, uv.y);

vec2 light = vec2(0.5, 0.8);
vec2 light = vec2(0.5, uLightY);
vec2 d = p - light;
float dist = length(d);
float ang = atan(d.y, d.x);

float t = uTime * 0.16;
float t = uTime * 0.55;
float r = 0.45
+ 0.30 * sin(ang * 7.0 + t)
+ 0.22 * sin(ang * 13.0 - t * 1.4 + 2.1)
Expand All @@ -67,12 +68,15 @@

float falloff = smoothstep(1.5, 0.05, dist);
float beams = r * falloff;
// Fade the beams out around the center so the convergence reads as a
// soft glow, not a hard point.
beams *= smoothstep(0.0, 0.26, dist);
float glow = smoothstep(0.85, 0.0, dist) * 0.16;

float v = (beams * 0.85 + glow) * uIntensity;
// Cyclic blend across the brand jewel tones by ray angle, so adjacent
// shafts differ in hue; drifts slowly over time.
float g = fract(ang / 6.28318 * 1.2 + uTime * 0.02);
float g = fract(ang / 6.28318 * 1.2 + uTime * 0.06);
vec3 col;
if (g < 0.33333) col = mix(uColorA, uColorB, g * 3.0);
else if (g < 0.66667) col = mix(uColorB, uColorC, (g - 0.33333) * 3.0);
Expand Down Expand Up @@ -126,6 +130,7 @@
const uColorA = gl.getUniformLocation(prog, "uColorA")
const uColorB = gl.getUniformLocation(prog, "uColorB")
const uColorC = gl.getUniformLocation(prog, "uColorC")
const uLightY = gl.getUniformLocation(prog, "uLightY")
gl.uniform1f(uIntensity, 0.62)
// Brand jewel tones (dark-mode tints): turquoise → indigo → magenta. No
// gold — these coordinate with the teal cards and complement the red hero.
Expand All @@ -148,11 +153,29 @@
gl!.uniform2f(uRes, w, h)
}

// The light source is anchored to the hero card's center: it tracks the
// card as the page scrolls, and pins at the top of the viewport once the
// card's center scrolls off the top — so the bright convergence never
// floats in the gaps between cards.
const hero = document.querySelector<HTMLElement>(".hero")
function lightY(): number {
if (!hero) return 0.8
const r = hero.getBoundingClientRect()
const vh = window.innerHeight || 1
const centerY = r.top + r.height / 2
// Track the hero card's center, but never let the source rise above 48px
// off the top — so the glowing convergence is fully off-screen by the
// time the card has scrolled past, never floating between the lower cards.
const pinned = Math.max(centerY, -48)
return 1 - pinned / vh
}

const start = performance.now()
let running = false
let rafId = 0
function frame(now: number): void {
gl!.uniform1f(uTime, (now - start) / 1000)
gl!.uniform1f(uLightY, lightY())
gl!.drawArrays(gl!.TRIANGLES, 0, 3)
if (running) rafId = requestAnimationFrame(frame)
}
Expand Down Expand Up @@ -199,6 +222,23 @@
else sync()
})

// When animating, the rAF loop re-anchors the light every frame. Under
// reduced motion we draw only a single frame, so redraw it on scroll so the
// anchored light still tracks the hero card.
let scrollPending = false
window.addEventListener(
"scroll",
() => {
if (running || !reduced || !darkMM.matches || scrollPending) return
scrollPending = true
requestAnimationFrame((now) => {
scrollPending = false
frame(now)
})
},
{ passive: true },
)

sync()
}
</script>
Expand Down
Loading
Loading