@@ -17,6 +17,7 @@ import { loadFilter, saveFilter } from "./storage";
1717import { emitAppEvent , APP_INSTALLED } from "@/lib/app-event-bus" ;
1818import { TaosAppsSection } from "./TaosAppsSection" ;
1919import { ImportAppButton } from "./ImportAppButton" ;
20+ import { LicenseAcceptDialog } from "./LicenseAcceptDialog" ;
2021import { useIsMobile } from "@/hooks/use-is-mobile" ;
2122import { MobileStore } from "./MobileStore" ;
2223import { AppIcon , StoreCover } from "./AppIcon" ;
@@ -419,6 +420,38 @@ function AppCard({
419420 const showVariantPicker = ! app . installed && variantOptions . length > 1 ;
420421 const showTargetPicker = ! app . installed && installTargets . length > 1 ;
421422
423+ interface LicenseGate { weightsLicense : string ; name : string ; text : string }
424+ const [ licenseGate , setLicenseGate ] = useState < LicenseGate | null > ( null ) ;
425+ const [ licenseSubmitting , setLicenseSubmitting ] = useState ( false ) ;
426+
427+ /** POST install-v2. Returns "ok" | "gated" | "error" — "gated" means the
428+ * backend returned 412 needs_license_acceptance (non-commercial weights,
429+ * #169) and licenseGate state is now set so the accept dialog renders. */
430+ const postInstall = async ( accepted : boolean ) : Promise < "ok" | "gated" | "error" > => {
431+ const body : Record < string , unknown > = { app_id : app . id , target_remote : selectedTarget } ;
432+ if ( selectedVariant !== "auto" ) body . variant_id = selectedVariant ;
433+ if ( accepted ) body . accepted = true ;
434+ const res = await fetch ( "/api/store/install-v2" , { method : "POST" , headers : { "Content-Type" : "application/json" , Accept : "application/json" } , body : JSON . stringify ( body ) } ) ;
435+ if ( res . status === 412 ) {
436+ try {
437+ const gate = await res . json ( ) ;
438+ if ( gate ?. needs_license_acceptance ) {
439+ setLicenseGate ( { weightsLicense : String ( gate . weights_license ?? "" ) , name : String ( gate . name ?? app . name ) , text : String ( gate . text ?? "" ) } ) ;
440+ return "gated" ;
441+ }
442+ } catch { /* fall through to generic error below */ }
443+ }
444+ if ( ! res . ok ) { let msg = `Install failed (${ res . status } )` ; try { const err = await res . json ( ) ; if ( err ?. error ) msg = String ( err . error ) ; } catch { /* ignore */ } setError ( msg ) ; return "error" ; }
445+ if ( isFramework ) {
446+ try {
447+ const data = await res . json ( ) ;
448+ if ( data ?. prefetch === "started" ) { setPrefetchStatus ( "downloading" ) ; setPrefetchActive ( true ) ; }
449+ } catch { /* no body / not framework prefetch */ }
450+ }
451+ onInstall ( app . id ) ;
452+ return "ok" ;
453+ } ;
454+
422455 const handleAction = async ( ) => {
423456 setBusy ( true ) ; setError ( null ) ; setProgress ( null ) ;
424457 try {
@@ -427,23 +460,31 @@ function AppCard({
427460 if ( ! res . ok ) { let msg = `Uninstall failed (${ res . status } )` ; try { const err = await res . json ( ) ; if ( err ?. error ) msg = String ( err . error ) ; } catch { /* ignore */ } setError ( msg ) ; setBusy ( false ) ; return ; }
428461 onUninstall ( app . id ) ;
429462 } else {
430- const body : Record < string , unknown > = { app_id : app . id , target_remote : selectedTarget } ;
431- if ( selectedVariant !== "auto" ) body . variant_id = selectedVariant ;
432- const res = await fetch ( "/api/store/install-v2" , { method : "POST" , headers : { "Content-Type" : "application/json" , Accept : "application/json" } , body : JSON . stringify ( body ) } ) ;
433- if ( ! res . ok ) { let msg = `Install failed (${ res . status } )` ; try { const err = await res . json ( ) ; if ( err ?. error ) msg = String ( err . error ) ; } catch { /* ignore */ } setError ( msg ) ; setBusy ( false ) ; return ; }
434- if ( isFramework ) {
435- try {
436- const data = await res . json ( ) ;
437- if ( data ?. prefetch === "started" ) { setPrefetchStatus ( "downloading" ) ; setPrefetchActive ( true ) ; }
438- } catch { /* no body / not framework prefetch */ }
439- }
440- onInstall ( app . id ) ;
463+ const outcome = await postInstall ( false ) ;
464+ if ( outcome === "gated" ) { setBusy ( false ) ; return ; }
441465 }
442466 } catch ( e ) { setError ( e instanceof Error ? e . message : "Network error" ) ; }
443467 setBusy ( false ) ;
444468 setTimeout ( ( ) => setProgress ( null ) , 1500 ) ;
445469 } ;
446470
471+ const handleLicenseAccept = async ( ) => {
472+ setLicenseSubmitting ( true ) ;
473+ // Flip busy back on so the card's install-progress poller (keyed on busy,
474+ // and cleared by handleAction when the gate returned) runs during the real
475+ // accepted install instead of the card sitting idle behind the dialog.
476+ setBusy ( true ) ;
477+ try {
478+ const outcome = await postInstall ( true ) ;
479+ if ( outcome !== "gated" ) setLicenseGate ( null ) ;
480+ } catch ( e ) { setError ( e instanceof Error ? e . message : "Network error" ) ; setLicenseGate ( null ) ; }
481+ setLicenseSubmitting ( false ) ;
482+ setBusy ( false ) ;
483+ setTimeout ( ( ) => setProgress ( null ) , 1500 ) ;
484+ } ;
485+
486+ const handleLicenseDecline = ( ) => { setLicenseGate ( null ) ; setBusy ( false ) ; } ;
487+
447488 const handleOpen = ( ) => {
448489 window . dispatchEvent ( new CustomEvent ( "taos:open-app" , { detail : { app : "agents" } } ) ) ;
449490 } ;
@@ -477,6 +518,15 @@ function AppCard({
477518 < span className = "text-[11px] text-shell-text-tertiary leading-none" > v{ app . version } </ span >
478519 </ div >
479520 </ div >
521+ { app . license_class === "non-commercial" && (
522+ < span
523+ className = "inline-flex items-center gap-1 self-start text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-yellow-700/30 text-yellow-200"
524+ title = { `Model weights licensed for non-commercial use${ app . weights_license ? ` (${ app . weights_license } )` : "" } ` }
525+ aria-label = { `${ app . name } uses non-commercial model weights${ app . weights_license ? `: ${ app . weights_license } ` : "" } ` }
526+ >
527+ Non-commercial weights
528+ </ span >
529+ ) }
480530 < p className = "text-[11.5px] text-shell-text-secondary leading-relaxed flex-1" > { app . description } </ p >
481531 < div className = "flex items-center justify-between" >
482532 { app . stars ? (
@@ -568,6 +618,17 @@ function AppCard({
568618 </ button >
569619 ) }
570620 </ div >
621+ { licenseGate && (
622+ < LicenseAcceptDialog
623+ appName = { licenseGate . name }
624+ weightsLicense = { licenseGate . weightsLicense }
625+ text = { licenseGate . text }
626+ submitting = { licenseSubmitting }
627+ error = { error }
628+ onAccept = { handleLicenseAccept }
629+ onDecline = { handleLicenseDecline }
630+ />
631+ ) }
571632 </ div >
572633 ) ;
573634}
@@ -967,6 +1028,9 @@ export function StoreApp({ windowId: _windowId }: { windowId: string }) {
9671028 tagline : a . tagline ? String ( a . tagline ) : undefined ,
9681029 cover : a . cover ? String ( a . cover ) : COVER_BY_ID [ String ( a . id ) ] ?. cover ,
9691030 coverImage : a . coverImage ? String ( a . coverImage ) : COVER_BY_ID [ String ( a . id ) ] ?. coverImage ,
1031+ license : a . license ? String ( a . license ) : undefined ,
1032+ weights_license : a . weights_license ? String ( a . weights_license ) : undefined ,
1033+ license_class : a . license_class ? String ( a . license_class ) : undefined ,
9701034 } ) ) ;
9711035 // Merge homelab apps: only add those not already in the catalog
9721036 const catalogIds = new Set ( normalized . map ( ( a ) => a . id ) ) ;
0 commit comments