Compare commits

...

5 Commits

45 changed files with 7880 additions and 465 deletions

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ build
.cxx
.DS_Store
/designer/test/*test_result.txt
/sandbox-test/test_results.json

View File

@@ -11,7 +11,7 @@ import (
"github.com/spf13/cobra"
"github.com/omixlab/mosis-portal/pkg/mospkg"
"omixlab.com/mosis-portal/pkg/mospkg"
)
// BuildCmd returns the build command

View File

@@ -11,7 +11,7 @@ import (
"github.com/spf13/cobra"
"github.com/omixlab/mosis-portal/pkg/mospkg"
"omixlab.com/mosis-portal/pkg/mospkg"
)
// InitCmd returns the init command

View File

@@ -15,7 +15,7 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/omixlab/mosis-portal/pkg/mospkg"
"omixlab.com/mosis-portal/pkg/mospkg"
)
// KeysCmd returns the keys command

View File

@@ -13,7 +13,7 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/omixlab/mosis-portal/pkg/mospkg"
"omixlab.com/mosis-portal/pkg/mospkg"
)
// PublishCmd returns the publish command

View File

@@ -9,7 +9,7 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/omixlab/mosis-portal/pkg/mospkg"
"omixlab.com/mosis-portal/pkg/mospkg"
)
// RunCmd returns the run command

View File

@@ -8,7 +8,7 @@ import (
"github.com/spf13/cobra"
"github.com/omixlab/mosis-portal/pkg/mospkg"
"omixlab.com/mosis-portal/pkg/mospkg"
)
// SignCmd returns the sign command

View File

@@ -12,7 +12,7 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/omixlab/mosis-portal/pkg/mospkg"
"omixlab.com/mosis-portal/pkg/mospkg"
)
// StatusCmd returns the status command

View File

@@ -10,7 +10,7 @@ import (
"github.com/spf13/cobra"
"github.com/omixlab/mosis-portal/pkg/mospkg"
"omixlab.com/mosis-portal/pkg/mospkg"
)
// ValidateCmd returns the validate command

View File

@@ -7,7 +7,7 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/omixlab/mosis-portal/cmd/mosis/cmd"
"omixlab.com/mosis-portal/cmd/mosis/cmd"
)
func main() {

View File

@@ -10,9 +10,9 @@ import (
"syscall"
"time"
"github.com/omixlab/mosis-portal/internal/api"
"github.com/omixlab/mosis-portal/internal/config"
"github.com/omixlab/mosis-portal/internal/database"
"omixlab.com/mosis-portal/internal/api"
"omixlab.com/mosis-portal/internal/config"
"omixlab.com/mosis-portal/internal/database"
)
func main() {

View File

@@ -1,4 +1,4 @@
module github.com/omixlab/mosis-portal
module omixlab.com/mosis-portal
go 1.22
@@ -9,6 +9,8 @@ require (
github.com/google/uuid v1.6.0
github.com/spf13/cobra v1.8.0
github.com/spf13/viper v1.18.2
github.com/yuin/goldmark v1.7.0
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
golang.org/x/crypto v0.21.0
golang.org/x/image v0.15.0
golang.org/x/oauth2 v0.18.0

View File

@@ -0,0 +1,226 @@
// Package handlers contains HTTP request handlers
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"omixlab.com/mosis-portal/internal/database"
"omixlab.com/mosis-portal/internal/review"
"omixlab.com/mosis-portal/internal/storage"
)
// AdminHandler handles admin operations
type AdminHandler struct {
db *database.DB
store *storage.Storage
review *review.Service
}
// NewAdminHandler creates a new admin handler
func NewAdminHandler(db *database.DB, store *storage.Storage) *AdminHandler {
return &AdminHandler{
db: db,
store: store,
review: review.New(db),
}
}
// Dashboard returns the admin dashboard with stats
func (h *AdminHandler) Dashboard(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
pending, approved, rejected, err := h.db.GetReviewStats(ctx)
if err != nil {
Error(w, http.StatusInternalServerError, "database_error", err.Error())
return
}
JSON(w, http.StatusOK, map[string]interface{}{
"stats": map[string]int{
"pending": pending,
"approved": approved,
"rejected": rejected,
},
})
}
// ReviewQueue lists versions pending review
func (h *AdminHandler) ReviewQueue(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Parse pagination
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit < 1 || limit > 100 {
limit = 20
}
offset := (page - 1) * limit
versions, total, err := h.db.GetVersionsInReview(ctx, limit, offset)
if err != nil {
Error(w, http.StatusInternalServerError, "database_error", err.Error())
return
}
JSON(w, http.StatusOK, map[string]interface{}{
"items": versions,
"pagination": map[string]int{
"page": page,
"limit": limit,
"total": total,
"total_pages": (total + limit - 1) / limit,
},
})
}
// ReviewDetail returns details for a specific version under review
func (h *AdminHandler) ReviewDetail(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
versionID := chi.URLParam(r, "versionID")
versionWithApp, err := h.db.GetVersionWithApp(ctx, versionID)
if err != nil {
Error(w, http.StatusNotFound, "not_found", "Version not found")
return
}
// If package exists, run validation to get flags
var validationResult *review.FullValidationResult
if versionWithApp.Version.PackageURL != "" {
packagePath := h.store.GetPackagePath(versionWithApp.Version.PackageURL)
result, err := h.review.ValidatePackage(packagePath)
if err == nil {
validationResult = result
}
}
JSON(w, http.StatusOK, map[string]interface{}{
"version": versionWithApp.Version,
"app": versionWithApp.App,
"developer": map[string]string{
"name": versionWithApp.DeveloperName,
"email": versionWithApp.DeveloperEmail,
},
"validation": validationResult,
})
}
// ApproveVersion approves a version
func (h *AdminHandler) ApproveVersion(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
versionID := chi.URLParam(r, "versionID")
// Parse request body
var req struct {
Notes string `json:"notes"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// Notes are optional, continue with empty
}
// Verify version exists and is in review
version, err := h.db.GetVersion(ctx, versionID)
if err != nil {
Error(w, http.StatusNotFound, "not_found", "Version not found")
return
}
if version.Status != "in_review" {
Error(w, http.StatusBadRequest, "invalid_status", "Version is not in review")
return
}
// Approve the version
if err := h.review.ApproveVersion(ctx, versionID, req.Notes); err != nil {
Error(w, http.StatusInternalServerError, "database_error", err.Error())
return
}
JSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "Version approved and published",
})
}
// RejectVersion rejects a version
func (h *AdminHandler) RejectVersion(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
versionID := chi.URLParam(r, "versionID")
// Parse request body
var req struct {
Reason string `json:"reason"`
Message string `json:"message"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, http.StatusBadRequest, "invalid_body", "Invalid request body")
return
}
if req.Reason == "" {
Error(w, http.StatusBadRequest, "missing_reason", "Rejection reason is required")
return
}
// Verify version exists and is in review
version, err := h.db.GetVersion(ctx, versionID)
if err != nil {
Error(w, http.StatusNotFound, "not_found", "Version not found")
return
}
if version.Status != "in_review" {
Error(w, http.StatusBadRequest, "invalid_status", "Version is not in review")
return
}
// Reject the version
feedback := &review.RejectionFeedback{
Reason: req.Reason,
Message: req.Message,
CanResubmit: true,
}
if err := h.review.RejectVersion(ctx, versionID, feedback); err != nil {
Error(w, http.StatusInternalServerError, "database_error", err.Error())
return
}
JSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "Version rejected",
})
}
// ValidatePackage runs validation on a package and returns results
func (h *AdminHandler) ValidatePackage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
versionID := chi.URLParam(r, "versionID")
// Get version with package info
version, err := h.db.GetVersion(ctx, versionID)
if err != nil {
Error(w, http.StatusNotFound, "not_found", "Version not found")
return
}
if version.PackageURL == "" {
Error(w, http.StatusBadRequest, "no_package", "Version has no uploaded package")
return
}
// Run validation
packagePath := h.store.GetPackagePath(version.PackageURL)
result, err := h.review.ValidatePackage(packagePath)
if err != nil {
Error(w, http.StatusInternalServerError, "validation_error", err.Error())
return
}
JSON(w, http.StatusOK, result)
}

View File

@@ -9,9 +9,9 @@ import (
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/omixlab/mosis-portal/internal/api/middleware"
"github.com/omixlab/mosis-portal/internal/database"
"github.com/omixlab/mosis-portal/internal/storage"
"omixlab.com/mosis-portal/internal/api/middleware"
"omixlab.com/mosis-portal/internal/database"
"omixlab.com/mosis-portal/internal/storage"
)
// AppHandler handles app-related endpoints

View File

@@ -6,9 +6,9 @@ import (
"net/http"
"time"
"github.com/omixlab/mosis-portal/internal/api/middleware"
"github.com/omixlab/mosis-portal/internal/auth"
"github.com/omixlab/mosis-portal/internal/database"
"omixlab.com/mosis-portal/internal/api/middleware"
"omixlab.com/mosis-portal/internal/auth"
"omixlab.com/mosis-portal/internal/database"
)
// AuthHandler handles authentication endpoints

View File

@@ -6,8 +6,8 @@ import (
"strconv"
"github.com/go-chi/chi/v5"
"github.com/omixlab/mosis-portal/internal/database"
"github.com/omixlab/mosis-portal/internal/storage"
"omixlab.com/mosis-portal/internal/database"
"omixlab.com/mosis-portal/internal/storage"
)
// StoreHandler handles public store endpoints

View File

@@ -0,0 +1,269 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"omixlab.com/mosis-portal/internal/database"
"omixlab.com/mosis-portal/internal/telemetry"
)
// TelemetryHandler handles telemetry API requests
type TelemetryHandler struct {
db *database.DB
telemetry *telemetry.Service
}
// NewTelemetryHandler creates a new telemetry handler
func NewTelemetryHandler(db *database.DB, ts *telemetry.Service) *TelemetryHandler {
return &TelemetryHandler{
db: db,
telemetry: ts,
}
}
// RecordEvents handles POST /v1/telemetry/events
func (h *TelemetryHandler) RecordEvents(w http.ResponseWriter, r *http.Request) {
var batch telemetry.EventBatch
if err := json.NewDecoder(r.Body).Decode(&batch); err != nil {
Error(w, http.StatusBadRequest, "invalid_body", "Invalid request body")
return
}
if batch.AppID == "" {
Error(w, http.StatusBadRequest, "missing_app_id", "App ID is required")
return
}
if batch.DeviceID == "" {
Error(w, http.StatusBadRequest, "missing_device_id", "Device ID is required")
return
}
if len(batch.Events) == 0 {
Error(w, http.StatusBadRequest, "no_events", "At least one event is required")
return
}
count, err := h.telemetry.RecordEvents(r.Context(), &batch)
if err != nil {
Error(w, http.StatusInternalServerError, "record_error", err.Error())
return
}
JSON(w, http.StatusOK, map[string]interface{}{
"received": count,
})
}
// RecordCrash handles POST /v1/telemetry/crash
func (h *TelemetryHandler) RecordCrash(w http.ResponseWriter, r *http.Request) {
var report telemetry.CrashReport
if err := json.NewDecoder(r.Body).Decode(&report); err != nil {
Error(w, http.StatusBadRequest, "invalid_body", "Invalid request body")
return
}
if report.AppID == "" {
Error(w, http.StatusBadRequest, "missing_app_id", "App ID is required")
return
}
if report.DeviceID == "" {
Error(w, http.StatusBadRequest, "missing_device_id", "Device ID is required")
return
}
if report.Crash.Type == "" {
Error(w, http.StatusBadRequest, "missing_crash_type", "Crash type is required")
return
}
groupID, err := h.telemetry.RecordCrash(r.Context(), &report)
if err != nil {
Error(w, http.StatusInternalServerError, "record_error", err.Error())
return
}
JSON(w, http.StatusOK, map[string]interface{}{
"id": groupID,
})
}
// GetAnalyticsOverview handles GET /v1/apps/:appID/analytics/overview
func (h *TelemetryHandler) GetAnalyticsOverview(w http.ResponseWriter, r *http.Request) {
appID := chi.URLParam(r, "appID")
// Verify app ownership
developerID := getDeveloperID(r)
app, err := h.db.GetApp(r.Context(), appID)
if err != nil {
Error(w, http.StatusNotFound, "not_found", "App not found")
return
}
if app.DeveloperID != developerID {
Error(w, http.StatusForbidden, "forbidden", "Access denied")
return
}
days := 30
if d := r.URL.Query().Get("days"); d != "" {
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 && parsed <= 365 {
days = parsed
}
}
overview, err := h.telemetry.GetAnalyticsOverview(r.Context(), appID, days)
if err != nil {
Error(w, http.StatusInternalServerError, "query_error", err.Error())
return
}
JSON(w, http.StatusOK, overview)
}
// GetAnalyticsEvents handles GET /v1/apps/:appID/analytics/events
func (h *TelemetryHandler) GetAnalyticsEvents(w http.ResponseWriter, r *http.Request) {
appID := chi.URLParam(r, "appID")
// Verify app ownership
developerID := getDeveloperID(r)
app, err := h.db.GetApp(r.Context(), appID)
if err != nil {
Error(w, http.StatusNotFound, "not_found", "App not found")
return
}
if app.DeveloperID != developerID {
Error(w, http.StatusForbidden, "forbidden", "Access denied")
return
}
eventType := r.URL.Query().Get("event_type")
days := 30
if d := r.URL.Query().Get("days"); d != "" {
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 && parsed <= 365 {
days = parsed
}
}
stats, err := h.telemetry.GetDailyStats(r.Context(), appID, eventType, days)
if err != nil {
Error(w, http.StatusInternalServerError, "query_error", err.Error())
return
}
JSON(w, http.StatusOK, map[string]interface{}{
"data": stats,
})
}
// GetCrashes handles GET /v1/apps/:appID/crashes
func (h *TelemetryHandler) GetCrashes(w http.ResponseWriter, r *http.Request) {
appID := chi.URLParam(r, "appID")
// Verify app ownership
developerID := getDeveloperID(r)
app, err := h.db.GetApp(r.Context(), appID)
if err != nil {
Error(w, http.StatusNotFound, "not_found", "App not found")
return
}
if app.DeveloperID != developerID {
Error(w, http.StatusForbidden, "forbidden", "Access denied")
return
}
status := r.URL.Query().Get("status")
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit < 1 || limit > 100 {
limit = 20
}
offset := (page - 1) * limit
crashes, total, err := h.telemetry.GetCrashGroups(r.Context(), appID, status, limit, offset)
if err != nil {
Error(w, http.StatusInternalServerError, "query_error", err.Error())
return
}
JSON(w, http.StatusOK, map[string]interface{}{
"crashes": crashes,
"pagination": map[string]int{
"page": page,
"limit": limit,
"total": total,
"total_pages": (total + limit - 1) / limit,
},
})
}
// GetCrash handles GET /v1/apps/:appID/crashes/:crashID
func (h *TelemetryHandler) GetCrash(w http.ResponseWriter, r *http.Request) {
appID := chi.URLParam(r, "appID")
crashID := chi.URLParam(r, "crashID")
// Verify app ownership
developerID := getDeveloperID(r)
app, err := h.db.GetApp(r.Context(), appID)
if err != nil {
Error(w, http.StatusNotFound, "not_found", "App not found")
return
}
if app.DeveloperID != developerID {
Error(w, http.StatusForbidden, "forbidden", "Access denied")
return
}
crash, err := h.telemetry.GetCrashGroup(r.Context(), appID, crashID)
if err != nil {
Error(w, http.StatusNotFound, "not_found", "Crash group not found")
return
}
JSON(w, http.StatusOK, crash)
}
// UpdateCrashStatus handles PATCH /v1/apps/:appID/crashes/:crashID
func (h *TelemetryHandler) UpdateCrashStatus(w http.ResponseWriter, r *http.Request) {
appID := chi.URLParam(r, "appID")
crashID := chi.URLParam(r, "crashID")
// Verify app ownership
developerID := getDeveloperID(r)
app, err := h.db.GetApp(r.Context(), appID)
if err != nil {
Error(w, http.StatusNotFound, "not_found", "App not found")
return
}
if app.DeveloperID != developerID {
Error(w, http.StatusForbidden, "forbidden", "Access denied")
return
}
var req struct {
Status string `json:"status"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, http.StatusBadRequest, "invalid_body", "Invalid request body")
return
}
if req.Status != "open" && req.Status != "resolved" && req.Status != "ignored" {
Error(w, http.StatusBadRequest, "invalid_status", "Status must be open, resolved, or ignored")
return
}
if err := h.telemetry.UpdateCrashGroupStatus(r.Context(), appID, crashID, req.Status); err != nil {
Error(w, http.StatusInternalServerError, "update_error", err.Error())
return
}
crash, _ := h.telemetry.GetCrashGroup(r.Context(), appID, crashID)
JSON(w, http.StatusOK, crash)
}

View File

@@ -6,8 +6,8 @@ import (
"net/http"
"strings"
"github.com/omixlab/mosis-portal/internal/auth"
"github.com/omixlab/mosis-portal/internal/database"
"omixlab.com/mosis-portal/internal/auth"
"omixlab.com/mosis-portal/internal/database"
)
type contextKey string

View File

@@ -7,13 +7,14 @@ import (
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/omixlab/mosis-portal/internal/api/handlers"
"github.com/omixlab/mosis-portal/internal/api/middleware"
"github.com/omixlab/mosis-portal/internal/auth"
"github.com/omixlab/mosis-portal/internal/config"
"github.com/omixlab/mosis-portal/internal/database"
"github.com/omixlab/mosis-portal/internal/storage"
"github.com/omixlab/mosis-portal/internal/web"
"omixlab.com/mosis-portal/internal/api/handlers"
"omixlab.com/mosis-portal/internal/api/middleware"
"omixlab.com/mosis-portal/internal/auth"
"omixlab.com/mosis-portal/internal/config"
"omixlab.com/mosis-portal/internal/database"
"omixlab.com/mosis-portal/internal/storage"
"omixlab.com/mosis-portal/internal/telemetry"
"omixlab.com/mosis-portal/internal/web"
)
// NewRouter creates and configures the HTTP router
@@ -32,6 +33,13 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
log.Fatalf("Failed to initialize storage: %v", err)
}
// Initialize telemetry service
telemetryDB := cfg.StoragePath + "/telemetry.db"
telemetrySvc, err := telemetry.New(telemetryDB)
if err != nil {
log.Fatalf("Failed to initialize telemetry service: %v", err)
}
// Initialize auth components
jwtManager := auth.NewJWTManager(cfg.JWTSecret)
oauthManager := auth.NewOAuthManager(
@@ -43,6 +51,8 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
authHandler := handlers.NewAuthHandler(oauthManager, jwtManager, db)
appHandler := handlers.NewAppHandler(db, store)
storeHandler := handlers.NewStoreHandler(db, store)
adminHandler := handlers.NewAdminHandler(db, store)
telemetryHandler := handlers.NewTelemetryHandler(db, telemetrySvc)
// Health check
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
@@ -94,6 +104,19 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
// Package upload
r.Post("/{versionID}/upload", appHandler.UploadPackage)
})
// Analytics
r.Route("/{appID}/analytics", func(r chi.Router) {
r.Get("/overview", telemetryHandler.GetAnalyticsOverview)
r.Get("/events", telemetryHandler.GetAnalyticsEvents)
})
// Crashes
r.Route("/{appID}/crashes", func(r chi.Router) {
r.Get("/", telemetryHandler.GetCrashes)
r.Get("/{crashID}", telemetryHandler.GetCrash)
r.Patch("/{crashID}", telemetryHandler.UpdateCrashStatus)
})
})
// API Keys
@@ -120,21 +143,22 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
r.Get("/apps/{packageID}/versions/{versionCode}/download", storeHandler.DownloadVersion)
})
// Telemetry (API key auth preferred, but can work without for initial setup)
// Telemetry endpoints (public - devices send events here)
r.Route("/telemetry", func(r chi.Router) {
r.Post("/events", handlers.NotImplemented)
r.Post("/crash", handlers.NotImplemented)
r.Post("/events", telemetryHandler.RecordEvents)
r.Post("/crash", telemetryHandler.RecordCrash)
})
})
// Admin routes (htmx UI) - requires auth
r.Route("/admin", func(r chi.Router) {
// Admin API routes (JSON responses)
r.Route("/api/admin", func(r chi.Router) {
r.Use(authMiddleware.RequireAuth)
r.Get("/", handlers.NotImplemented)
r.Get("/review-queue", handlers.NotImplemented)
r.Get("/review/{versionID}", handlers.NotImplemented)
r.Post("/review/{versionID}/approve", handlers.NotImplemented)
r.Post("/review/{versionID}/reject", handlers.NotImplemented)
r.Get("/stats", adminHandler.Dashboard)
r.Get("/review-queue", adminHandler.ReviewQueue)
r.Get("/review/{versionID}", adminHandler.ReviewDetail)
r.Post("/review/{versionID}/approve", adminHandler.ApproveVersion)
r.Post("/review/{versionID}/reject", adminHandler.RejectVersion)
r.Get("/review/{versionID}/validate", adminHandler.ValidatePackage)
})
// Web UI routes (htmx + Go templates)
@@ -142,6 +166,8 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
if err != nil {
log.Printf("Warning: Failed to initialize web handler: %v", err)
} else {
webHandler.SetStorage(store)
webHandler.SetTelemetry(telemetrySvc)
sessionMW := web.NewSessionMiddleware(db, cfg.JWTSecret)
// Public web pages
@@ -160,9 +186,19 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
r.Get("/dashboard", webHandler.Dashboard)
r.Get("/apps/new", webHandler.AppNew)
r.Get("/apps/{appID}", webHandler.AppDetail)
r.Get("/apps/{appID}/analytics", webHandler.AppAnalytics)
r.Get("/apps/{appID}/crashes", webHandler.AppCrashes)
// htmx partials
r.Get("/partials/apps", webHandler.AppListPartial)
// Admin pages (htmx UI)
r.Get("/admin/review-queue", webHandler.AdminReviewQueue)
r.Get("/admin/review/{versionID}", webHandler.AdminReviewDetail)
r.Get("/admin/partials/review-queue", webHandler.AdminReviewQueuePartial)
r.Post("/admin/review/{versionID}/approve", webHandler.AdminApprove)
r.Post("/admin/review/{versionID}/reject", webHandler.AdminReject)
r.Get("/admin/review/{versionID}/validate", webHandler.AdminValidate)
})
// Auth callback that sets session (after OAuth)
@@ -183,6 +219,15 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
})
}
// Documentation site
docsHandler, err := web.NewDocsHandler()
if err != nil {
log.Printf("Warning: Failed to initialize docs handler: %v", err)
} else {
r.Handle("/docs", docsHandler)
r.Handle("/docs/*", docsHandler)
}
// Static file servers for packages and assets
// Downloads - serve package files with proper headers
r.Handle("/downloads/*", http.StripPrefix("/downloads/",

View File

@@ -878,3 +878,209 @@ func (db *DB) GetPublishedVersionByCode(ctx context.Context, packageID string, v
return scanVersion(row)
}
// VersionWithApp combines version data with its parent app data for review display
type VersionWithApp struct {
Version *AppVersion `json:"version"`
App *App `json:"app"`
DeveloperName string `json:"developer_name"`
DeveloperEmail string `json:"developer_email"`
}
// GetVersionsInReview returns versions pending review with pagination
func (db *DB) GetVersionsInReview(ctx context.Context, limit, offset int) ([]VersionWithApp, int, error) {
// Get total count
var total int
err := db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM app_versions WHERE status = 'in_review'
`).Scan(&total)
if err != nil {
return nil, 0, err
}
// Query versions with app and developer info
rows, err := db.QueryContext(ctx, `
SELECT v.id, v.app_id, v.version_code, v.version_name, v.package_url, v.package_size, v.signature,
v.permissions, v.min_mosis_version, v.release_notes, v.status, v.review_notes, v.published_at, v.created_at,
a.id, a.developer_id, a.package_id, a.name, a.description, a.category, a.tags, a.status, a.created_at, a.updated_at,
d.name, d.email
FROM app_versions v
JOIN apps a ON a.id = v.app_id
JOIN developers d ON d.id = a.developer_id
WHERE v.status = 'in_review'
ORDER BY v.created_at ASC
LIMIT ? OFFSET ?
`, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var results []VersionWithApp
for rows.Next() {
var vwa VersionWithApp
var v AppVersion
var app App
var vPackageURL, vSignature, vPermsJSON, vMinVersion, vReleaseNotes, vReviewNotes sql.NullString
var vPublishedAt, vCreatedAt sql.NullString
var vPackageSize sql.NullInt64
var aDesc, aCat, aTagsJSON sql.NullString
var aCreatedAt, aUpdatedAt string
err := rows.Scan(
&v.ID, &v.AppID, &v.VersionCode, &v.VersionName, &vPackageURL, &vPackageSize, &vSignature,
&vPermsJSON, &vMinVersion, &vReleaseNotes, &v.Status, &vReviewNotes, &vPublishedAt, &vCreatedAt,
&app.ID, &app.DeveloperID, &app.PackageID, &app.Name, &aDesc, &aCat, &aTagsJSON, &app.Status, &aCreatedAt, &aUpdatedAt,
&vwa.DeveloperName, &vwa.DeveloperEmail,
)
if err != nil {
continue
}
// Populate version
v.PackageURL = vPackageURL.String
v.PackageSize = vPackageSize.Int64
v.Signature = vSignature.String
v.MinMosisVersion = vMinVersion.String
v.ReleaseNotes = vReleaseNotes.String
v.ReviewNotes = vReviewNotes.String
v.Permissions = []string{}
if vPermsJSON.Valid && vPermsJSON.String != "" {
json.Unmarshal([]byte(vPermsJSON.String), &v.Permissions)
}
if vCreatedAt.Valid {
v.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", vCreatedAt.String)
}
if vPublishedAt.Valid {
t, _ := time.Parse("2006-01-02 15:04:05", vPublishedAt.String)
v.PublishedAt = &t
}
// Populate app
app.Description = aDesc.String
app.Category = aCat.String
app.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", aCreatedAt)
app.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", aUpdatedAt)
app.Tags = []string{}
if aTagsJSON.Valid && aTagsJSON.String != "" {
json.Unmarshal([]byte(aTagsJSON.String), &app.Tags)
}
vwa.Version = &v
vwa.App = &app
results = append(results, vwa)
}
return results, total, nil
}
// GetVersionWithApp retrieves a version with its app and developer info
func (db *DB) GetVersionWithApp(ctx context.Context, versionID string) (*VersionWithApp, error) {
row := db.QueryRowContext(ctx, `
SELECT v.id, v.app_id, v.version_code, v.version_name, v.package_url, v.package_size, v.signature,
v.permissions, v.min_mosis_version, v.release_notes, v.status, v.review_notes, v.published_at, v.created_at,
a.id, a.developer_id, a.package_id, a.name, a.description, a.category, a.tags, a.status, a.created_at, a.updated_at,
d.name, d.email
FROM app_versions v
JOIN apps a ON a.id = v.app_id
JOIN developers d ON d.id = a.developer_id
WHERE v.id = ?
`, versionID)
var vwa VersionWithApp
var v AppVersion
var app App
var vPackageURL, vSignature, vPermsJSON, vMinVersion, vReleaseNotes, vReviewNotes sql.NullString
var vPublishedAt, vCreatedAt sql.NullString
var vPackageSize sql.NullInt64
var aDesc, aCat, aTagsJSON sql.NullString
var aCreatedAt, aUpdatedAt string
err := row.Scan(
&v.ID, &v.AppID, &v.VersionCode, &v.VersionName, &vPackageURL, &vPackageSize, &vSignature,
&vPermsJSON, &vMinVersion, &vReleaseNotes, &v.Status, &vReviewNotes, &vPublishedAt, &vCreatedAt,
&app.ID, &app.DeveloperID, &app.PackageID, &app.Name, &aDesc, &aCat, &aTagsJSON, &app.Status, &aCreatedAt, &aUpdatedAt,
&vwa.DeveloperName, &vwa.DeveloperEmail,
)
if err != nil {
return nil, err
}
// Populate version
v.PackageURL = vPackageURL.String
v.PackageSize = vPackageSize.Int64
v.Signature = vSignature.String
v.MinMosisVersion = vMinVersion.String
v.ReleaseNotes = vReleaseNotes.String
v.ReviewNotes = vReviewNotes.String
v.Permissions = []string{}
if vPermsJSON.Valid && vPermsJSON.String != "" {
json.Unmarshal([]byte(vPermsJSON.String), &v.Permissions)
}
if vCreatedAt.Valid {
v.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", vCreatedAt.String)
}
if vPublishedAt.Valid {
t, _ := time.Parse("2006-01-02 15:04:05", vPublishedAt.String)
v.PublishedAt = &t
}
// Populate app
app.Description = aDesc.String
app.Category = aCat.String
app.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", aCreatedAt)
app.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", aUpdatedAt)
app.Tags = []string{}
if aTagsJSON.Valid && aTagsJSON.String != "" {
json.Unmarshal([]byte(aTagsJSON.String), &app.Tags)
}
vwa.Version = &v
vwa.App = &app
return &vwa, nil
}
// ApproveVersion approves a version and optionally publishes it
func (db *DB) ApproveVersion(ctx context.Context, versionID, reviewerNotes string) error {
_, err := db.ExecContext(ctx, `
UPDATE app_versions
SET status = 'published', review_notes = ?, published_at = datetime('now')
WHERE id = ?
`, reviewerNotes, versionID)
if err != nil {
return err
}
// Also update the app status to published
_, err = db.ExecContext(ctx, `
UPDATE apps SET status = 'published', updated_at = datetime('now')
WHERE id = (SELECT app_id FROM app_versions WHERE id = ?)
`, versionID)
return err
}
// RejectVersion rejects a version with feedback
func (db *DB) RejectVersion(ctx context.Context, versionID, reason, message string) error {
notes := reason
if message != "" {
notes = reason + ": " + message
}
_, err := db.ExecContext(ctx, `
UPDATE app_versions SET status = 'rejected', review_notes = ? WHERE id = ?
`, notes, versionID)
return err
}
// GetReviewStats returns statistics about the review queue
func (db *DB) GetReviewStats(ctx context.Context) (pending, approved, rejected int, err error) {
err = db.QueryRowContext(ctx, `
SELECT
COALESCE(SUM(CASE WHEN status = 'in_review' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'published' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END), 0)
FROM app_versions
`).Scan(&pending, &approved, &rejected)
return
}

View File

@@ -0,0 +1,405 @@
// Package review provides app review and validation services
package review
import (
"archive/zip"
"context"
"fmt"
"io"
"regexp"
"strings"
"time"
"omixlab.com/mosis-portal/internal/database"
"omixlab.com/mosis-portal/pkg/mospkg"
)
// ReviewFlag represents a security or quality flag
type ReviewFlag struct {
Type string `json:"type"`
Severity string `json:"severity"` // "info", "warning", "critical"
Reason string `json:"reason"`
File string `json:"file,omitempty"`
Line int `json:"line,omitempty"`
}
// FullValidationResult extends ValidationResult with security flags
type FullValidationResult struct {
*mospkg.ValidationResult
Flags []ReviewFlag `json:"flags,omitempty"`
RequiresManual bool `json:"requires_manual"`
AutoApprovable bool `json:"auto_approvable"`
ValidationTimeMs int64 `json:"validation_time_ms"`
}
// Service handles app review operations
type Service struct {
db *database.DB
}
// New creates a new review service
func New(db *database.DB) *Service {
return &Service{db: db}
}
// ValidatePackage performs full validation on a package
func (s *Service) ValidatePackage(packagePath string) (*FullValidationResult, error) {
start := time.Now()
// Run basic validation
basicResult, err := mospkg.ValidatePackage(packagePath)
if err != nil {
return nil, err
}
result := &FullValidationResult{
ValidationResult: basicResult,
Flags: []ReviewFlag{},
AutoApprovable: true,
}
// If basic validation failed, no need to continue
if !basicResult.Valid {
result.AutoApprovable = false
result.ValidationTimeMs = time.Since(start).Milliseconds()
return result, nil
}
// Run security analysis (Tier 3)
flags, err := s.analyzeSecurityRisks(packagePath)
if err != nil {
return nil, err
}
result.Flags = append(result.Flags, flags...)
// Run quality checks (Tier 4)
qualityFlags := s.checkQuality(basicResult.Manifest)
result.Flags = append(result.Flags, qualityFlags...)
// Determine if manual review is required
result.RequiresManual = s.requiresManualReview(result)
if result.RequiresManual {
result.AutoApprovable = false
}
// Check for critical flags
for _, flag := range result.Flags {
if flag.Severity == "critical" {
result.AutoApprovable = false
}
}
result.ValidationTimeMs = time.Since(start).Milliseconds()
return result, nil
}
// SubmitForReview submits a version for review
func (s *Service) SubmitForReview(ctx context.Context, versionID string) error {
return s.db.UpdateVersionStatus(ctx, versionID, "in_review")
}
// ApproveVersion approves a version
func (s *Service) ApproveVersion(ctx context.Context, versionID, reviewerNotes string) error {
return s.db.ApproveVersion(ctx, versionID, reviewerNotes)
}
// RejectVersion rejects a version with feedback
func (s *Service) RejectVersion(ctx context.Context, versionID string, feedback *RejectionFeedback) error {
return s.db.RejectVersion(ctx, versionID, feedback.Reason, feedback.Message)
}
// RejectionFeedback contains rejection details
type RejectionFeedback struct {
Reason string `json:"reason"`
Message string `json:"message"`
Details []RejectionDetail `json:"details,omitempty"`
CanResubmit bool `json:"can_resubmit"`
}
// RejectionDetail provides specific feedback for an issue
type RejectionDetail struct {
File string `json:"file"`
Line int `json:"line,omitempty"`
Issue string `json:"issue"`
Suggestion string `json:"suggestion,omitempty"`
}
// Dangerous patterns to detect in Lua code
var dangerousPatterns = []struct {
Pattern *regexp.Regexp
Reason string
Severity string
}{
{
regexp.MustCompile(`loadstring\s*\(`),
"Dynamic code execution via loadstring",
"critical",
},
{
regexp.MustCompile(`load\s*\([^)]*\)`),
"Dynamic code loading",
"critical",
},
{
regexp.MustCompile(`debug\s*\.\s*\w+`),
"Debug library usage",
"critical",
},
{
regexp.MustCompile(`os\s*\.\s*execute`),
"OS command execution",
"critical",
},
{
regexp.MustCompile(`os\s*\.\s*remove`),
"File deletion via os.remove",
"critical",
},
{
regexp.MustCompile(`io\s*\.\s*(open|popen|read|write|lines)`),
"Direct file I/O operations",
"critical",
},
{
regexp.MustCompile(`ffi\s*\.\s*\w+`),
"FFI (foreign function interface) usage",
"critical",
},
{
regexp.MustCompile(`package\s*\.\s*loadlib`),
"Native library loading",
"critical",
},
{
regexp.MustCompile(`package\s*\.\s*cpath`),
"C library path modification",
"critical",
},
{
regexp.MustCompile(`rawset\s*\(\s*_G`),
"Global environment modification",
"warning",
},
{
regexp.MustCompile(`setfenv\s*\(`),
"Environment modification",
"warning",
},
{
regexp.MustCompile(`getfenv\s*\(`),
"Environment access",
"warning",
},
{
regexp.MustCompile(`https?://[^\s"']+`),
"Hardcoded external URL",
"info",
},
{
regexp.MustCompile(`require\s*\(\s*["'][^"']+["']\s*\)`),
"Module require (verify allowed modules)",
"info",
},
}
func (s *Service) analyzeSecurityRisks(packagePath string) ([]ReviewFlag, error) {
var flags []ReviewFlag
reader, err := zip.OpenReader(packagePath)
if err != nil {
return nil, err
}
defer reader.Close()
for _, file := range reader.File {
if file.FileInfo().IsDir() {
continue
}
ext := strings.ToLower(strings.TrimPrefix(file.Name, "."))
if !strings.HasSuffix(file.Name, ".lua") {
continue
}
// Read Lua file content
rc, err := file.Open()
if err != nil {
continue
}
content, err := io.ReadAll(io.LimitReader(rc, 1024*1024)) // 1MB limit
rc.Close()
if err != nil {
continue
}
contentStr := string(content)
// Check against dangerous patterns
for _, dp := range dangerousPatterns {
if dp.Pattern.MatchString(contentStr) {
// Find line number
lineNum := findLineNumber(contentStr, dp.Pattern)
flags = append(flags, ReviewFlag{
Type: "SECURITY",
Severity: dp.Severity,
Reason: dp.Reason,
File: file.Name,
Line: lineNum,
})
}
}
// Check for obfuscated code (high entropy, meaningless variable names)
if isLikelyObfuscated(contentStr) {
flags = append(flags, ReviewFlag{
Type: "SECURITY",
Severity: "warning",
Reason: "Code appears to be obfuscated",
File: file.Name,
})
}
_ = ext // Unused but may be useful for future file type checks
}
return flags, nil
}
func findLineNumber(content string, pattern *regexp.Regexp) int {
loc := pattern.FindStringIndex(content)
if loc == nil {
return 0
}
lineNum := 1
for i := 0; i < loc[0] && i < len(content); i++ {
if content[i] == '\n' {
lineNum++
}
}
return lineNum
}
func isLikelyObfuscated(content string) bool {
// Simple heuristics for obfuscation detection:
// 1. High ratio of single-character variable names
// 2. Many string.char() calls
// 3. Long lines with minimal whitespace
singleCharVars := regexp.MustCompile(`\blocal\s+[a-z]\s*=`)
matches := singleCharVars.FindAllString(content, -1)
if len(matches) > 20 {
return true
}
stringCharCalls := strings.Count(content, "string.char")
if stringCharCalls > 10 {
return true
}
// Check for long lines (obfuscators often produce very long lines)
lines := strings.Split(content, "\n")
longLines := 0
for _, line := range lines {
if len(line) > 500 {
longLines++
}
}
if longLines > 3 {
return true
}
return false
}
func (s *Service) checkQuality(manifest *mospkg.Manifest) []ReviewFlag {
var flags []ReviewFlag
if manifest == nil {
return flags
}
// Check description length
if len(manifest.Description) < 10 {
flags = append(flags, ReviewFlag{
Type: "QUALITY",
Severity: "info",
Reason: "Description is very short (less than 10 characters)",
})
}
// Check for placeholder icon paths
if manifest.Icons.Size32 == "" && manifest.Icons.Size64 == "" && manifest.Icons.Size128 == "" {
flags = append(flags, ReviewFlag{
Type: "QUALITY",
Severity: "warning",
Reason: "No icons specified in manifest",
})
}
// Check for sensitive permissions
sensitivePerms := map[string]bool{
"camera": true,
"microphone": true,
"contacts": true,
"location": true,
}
for _, perm := range manifest.Permissions {
if sensitivePerms[perm] {
flags = append(flags, ReviewFlag{
Type: "PERMISSION",
Severity: "info",
Reason: fmt.Sprintf("App requests sensitive permission: %s", perm),
})
}
}
return flags
}
func (s *Service) requiresManualReview(result *FullValidationResult) bool {
// Always require manual review for:
// 1. Any critical security flags
// 2. More than 3 warnings
// 3. Sensitive permissions
criticalCount := 0
warningCount := 0
hasSensitivePerms := false
for _, flag := range result.Flags {
switch flag.Severity {
case "critical":
criticalCount++
case "warning":
warningCount++
}
if flag.Type == "PERMISSION" {
hasSensitivePerms = true
}
}
if criticalCount > 0 {
return true
}
if warningCount > 3 {
return true
}
if hasSensitivePerms {
return true
}
return false
}
// GetReviewQueue returns versions pending review
func (s *Service) GetReviewQueue(ctx context.Context, limit, offset int) ([]database.VersionWithApp, int, error) {
return s.db.GetVersionsInReview(ctx, limit, offset)
}
// GetReviewDetails returns details for a specific version under review
func (s *Service) GetReviewDetails(ctx context.Context, versionID string) (*database.VersionWithApp, error) {
return s.db.GetVersionWithApp(ctx, versionID)
}

View File

@@ -186,6 +186,17 @@ func (s *Storage) DeleteAppAssets(appID string) error {
return os.RemoveAll(dir)
}
// GetPackagePath resolves a package URL (stored in DB) to a filesystem path
// Package URLs are stored as relative paths like "packages/{developerID}/{appID}/{versionCode}/package.mosis"
func (s *Storage) GetPackagePath(packageURL string) string {
// If it's already an absolute path, return as-is
if filepath.IsAbs(packageURL) {
return packageURL
}
// Otherwise, join with base path
return filepath.Join(s.basePath, packageURL)
}
// copyFile copies a file from src to dst
func copyFile(src, dst string) error {
in, err := os.Open(src)

View File

@@ -0,0 +1,666 @@
// Package telemetry provides app analytics and crash reporting
package telemetry
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"regexp"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
)
// Event types
const (
EventAppStart = "app_start"
EventAppStop = "app_stop"
EventAppCrash = "app_crash"
EventLuaError = "lua_error"
EventPerfFrame = "perf_frame"
EventPerfMemory = "perf_memory"
EventPerfStartup = "perf_startup"
EventScreenView = "screen_view"
EventFeatureUsed = "feature_used"
)
// Event represents a telemetry event
type Event struct {
Type string `json:"type"`
Timestamp string `json:"timestamp"`
Data json.RawMessage `json:"data,omitempty"`
}
// EventBatch represents a batch of events from a device
type EventBatch struct {
AppID string `json:"app_id"`
AppVersion string `json:"app_version"`
MosisVersion string `json:"mosis_version"`
DeviceID string `json:"device_id"`
SessionID string `json:"session_id,omitempty"`
Events []Event `json:"events"`
}
// CrashReport represents a crash report from a device
type CrashReport struct {
AppID string `json:"app_id"`
AppVersion string `json:"app_version"`
MosisVersion string `json:"mosis_version"`
DeviceID string `json:"device_id"`
Timestamp string `json:"timestamp"`
Crash CrashDetails `json:"crash"`
}
// CrashDetails contains crash information
type CrashDetails struct {
Type string `json:"type"`
Message string `json:"message"`
StackTrace string `json:"stack_trace"`
Context map[string]interface{} `json:"context,omitempty"`
}
// CrashGroup represents a group of similar crashes
type CrashGroup struct {
ID string `json:"id"`
AppID string `json:"app_id"`
Fingerprint string `json:"fingerprint"`
CrashType string `json:"crash_type"`
Message string `json:"message"`
SampleStackTrace string `json:"sample_stack_trace"`
FirstSeen string `json:"first_seen"`
LastSeen string `json:"last_seen"`
OccurrenceCount int `json:"occurrence_count"`
AffectedVersions []string `json:"affected_versions"`
Status string `json:"status"` // open, resolved, ignored
}
// DailyStats represents aggregated daily statistics
type DailyStats struct {
AppID string `json:"app_id"`
Date string `json:"date"`
EventType string `json:"event_type"`
Count int `json:"count"`
UniqueDevices int `json:"unique_devices"`
}
// AnalyticsOverview represents the analytics summary for an app
type AnalyticsOverview struct {
DAU int `json:"dau"`
DAUChange float64 `json:"dau_change"`
TotalCrashes int `json:"total_crashes"`
CrashChange float64 `json:"crash_change"`
CrashFreeRate float64 `json:"crash_free_rate"`
TotalSessions int `json:"total_sessions"`
}
// Service handles telemetry operations
type Service struct {
db *sql.DB
dbPath string
mu sync.Mutex
stopCh chan struct{}
}
// New creates a new telemetry service with a separate database
func New(dbPath string) (*Service, error) {
db, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
if err != nil {
return nil, fmt.Errorf("open telemetry db: %w", err)
}
s := &Service{
db: db,
dbPath: dbPath,
stopCh: make(chan struct{}),
}
if err := s.migrate(); err != nil {
db.Close()
return nil, fmt.Errorf("migrate telemetry db: %w", err)
}
return s, nil
}
// migrate creates the telemetry database schema
func (s *Service) migrate() error {
schema := `
-- Raw events (7-day retention)
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
app_id TEXT NOT NULL,
device_id TEXT NOT NULL,
session_id TEXT,
event_type TEXT NOT NULL,
event_data TEXT,
app_version TEXT,
mosis_version TEXT,
timestamp TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_events_app_time ON events(app_id, timestamp);
CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type, timestamp);
CREATE INDEX IF NOT EXISTS idx_events_cleanup ON events(timestamp);
-- Hourly aggregates
CREATE TABLE IF NOT EXISTS hourly_stats (
app_id TEXT NOT NULL,
hour TEXT NOT NULL,
event_type TEXT NOT NULL,
count INTEGER NOT NULL,
unique_devices INTEGER NOT NULL,
PRIMARY KEY (app_id, hour, event_type)
);
-- Daily aggregates
CREATE TABLE IF NOT EXISTS daily_stats (
app_id TEXT NOT NULL,
date TEXT NOT NULL,
event_type TEXT NOT NULL,
count INTEGER NOT NULL,
unique_devices INTEGER NOT NULL,
PRIMARY KEY (app_id, date, event_type)
);
-- Crash groups (deduplicated by fingerprint)
CREATE TABLE IF NOT EXISTS crash_groups (
id TEXT PRIMARY KEY,
app_id TEXT NOT NULL,
fingerprint TEXT NOT NULL,
crash_type TEXT NOT NULL,
message TEXT,
sample_stack_trace TEXT,
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL,
occurrence_count INTEGER DEFAULT 1,
affected_versions TEXT,
status TEXT DEFAULT 'open',
UNIQUE(app_id, fingerprint)
);
CREATE INDEX IF NOT EXISTS idx_crashes_app ON crash_groups(app_id, status);
-- Individual crash occurrences (for recent list)
CREATE TABLE IF NOT EXISTS crash_occurrences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
crash_group_id TEXT NOT NULL,
device_id TEXT NOT NULL,
app_version TEXT,
context TEXT,
timestamp TEXT NOT NULL,
FOREIGN KEY (crash_group_id) REFERENCES crash_groups(id)
);
CREATE INDEX IF NOT EXISTS idx_occurrences_group ON crash_occurrences(crash_group_id, timestamp);
`
_, err := s.db.Exec(schema)
return err
}
// Close closes the telemetry database
func (s *Service) Close() error {
close(s.stopCh)
return s.db.Close()
}
// RecordEvents records a batch of events
func (s *Service) RecordEvents(ctx context.Context, batch *EventBatch) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO events (app_id, device_id, session_id, event_type, event_data, app_version, mosis_version, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`)
if err != nil {
return 0, err
}
defer stmt.Close()
count := 0
for _, event := range batch.Events {
var eventData string
if event.Data != nil {
eventData = string(event.Data)
}
_, err := stmt.ExecContext(ctx,
batch.AppID,
batch.DeviceID,
batch.SessionID,
event.Type,
eventData,
batch.AppVersion,
batch.MosisVersion,
event.Timestamp,
)
if err != nil {
log.Printf("Failed to insert event: %v", err)
continue
}
count++
}
if err := tx.Commit(); err != nil {
return 0, err
}
return count, nil
}
// RecordCrash records a crash report
func (s *Service) RecordCrash(ctx context.Context, report *CrashReport) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
// Generate fingerprint for crash grouping
fingerprint := s.fingerprintCrash(report)
groupID := generateID()
// Try to find existing crash group
var existingID string
var affectedVersions string
err := s.db.QueryRowContext(ctx, `
SELECT id, affected_versions FROM crash_groups WHERE app_id = ? AND fingerprint = ?
`, report.AppID, fingerprint).Scan(&existingID, &affectedVersions)
if err == sql.ErrNoRows {
// Create new crash group
versions, _ := json.Marshal([]string{report.AppVersion})
_, err = s.db.ExecContext(ctx, `
INSERT INTO crash_groups (id, app_id, fingerprint, crash_type, message, sample_stack_trace, first_seen, last_seen, occurrence_count, affected_versions, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, 'open')
`, groupID, report.AppID, fingerprint, report.Crash.Type, report.Crash.Message, report.Crash.StackTrace, report.Timestamp, report.Timestamp, string(versions))
if err != nil {
return "", err
}
} else if err != nil {
return "", err
} else {
// Update existing crash group
groupID = existingID
// Add version if not already in list
var versions []string
json.Unmarshal([]byte(affectedVersions), &versions)
if !contains(versions, report.AppVersion) {
versions = append(versions, report.AppVersion)
}
versionsJSON, _ := json.Marshal(versions)
_, err = s.db.ExecContext(ctx, `
UPDATE crash_groups SET
last_seen = ?,
occurrence_count = occurrence_count + 1,
affected_versions = ?,
status = CASE WHEN status = 'resolved' THEN 'open' ELSE status END
WHERE id = ?
`, report.Timestamp, string(versionsJSON), groupID)
if err != nil {
return "", err
}
}
// Record individual occurrence
contextJSON, _ := json.Marshal(report.Crash.Context)
_, err = s.db.ExecContext(ctx, `
INSERT INTO crash_occurrences (crash_group_id, device_id, app_version, context, timestamp)
VALUES (?, ?, ?, ?, ?)
`, groupID, report.DeviceID, report.AppVersion, string(contextJSON), report.Timestamp)
return groupID, err
}
// fingerprintCrash generates a unique fingerprint for crash grouping
func (s *Service) fingerprintCrash(report *CrashReport) string {
// Normalize stack trace (remove line numbers)
normalized := normalizeStackTrace(report.Crash.StackTrace)
// Create fingerprint from type, message, and normalized stack
key := fmt.Sprintf("%s:%s:%s", report.Crash.Type, report.Crash.Message, normalized)
hash := sha256.Sum256([]byte(key))
return hex.EncodeToString(hash[:8])
}
// normalizeStackTrace removes line numbers for consistent fingerprinting
func normalizeStackTrace(stack string) string {
re := regexp.MustCompile(`:\d+:`)
return re.ReplaceAllString(stack, ":?:")
}
// GetAnalyticsOverview returns analytics summary for an app
func (s *Service) GetAnalyticsOverview(ctx context.Context, appID string, days int) (*AnalyticsOverview, error) {
endDate := time.Now()
startDate := endDate.AddDate(0, 0, -days)
prevStartDate := startDate.AddDate(0, 0, -days)
// Current period DAU (average)
var currentDAU float64
err := s.db.QueryRowContext(ctx, `
SELECT COALESCE(AVG(unique_devices), 0) FROM daily_stats
WHERE app_id = ? AND event_type = 'app_start' AND date >= ? AND date <= ?
`, appID, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")).Scan(&currentDAU)
if err != nil {
return nil, err
}
// Previous period DAU
var prevDAU float64
s.db.QueryRowContext(ctx, `
SELECT COALESCE(AVG(unique_devices), 0) FROM daily_stats
WHERE app_id = ? AND event_type = 'app_start' AND date >= ? AND date < ?
`, appID, prevStartDate.Format("2006-01-02"), startDate.Format("2006-01-02")).Scan(&prevDAU)
// Current crashes
var currentCrashes int
s.db.QueryRowContext(ctx, `
SELECT COALESCE(SUM(count), 0) FROM daily_stats
WHERE app_id = ? AND event_type = 'app_crash' AND date >= ? AND date <= ?
`, appID, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")).Scan(&currentCrashes)
// Previous crashes
var prevCrashes int
s.db.QueryRowContext(ctx, `
SELECT COALESCE(SUM(count), 0) FROM daily_stats
WHERE app_id = ? AND event_type = 'app_crash' AND date >= ? AND date < ?
`, appID, prevStartDate.Format("2006-01-02"), startDate.Format("2006-01-02")).Scan(&prevCrashes)
// Total sessions
var totalSessions int
s.db.QueryRowContext(ctx, `
SELECT COALESCE(SUM(count), 0) FROM daily_stats
WHERE app_id = ? AND event_type = 'app_start' AND date >= ? AND date <= ?
`, appID, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")).Scan(&totalSessions)
// Calculate changes
dauChange := 0.0
if prevDAU > 0 {
dauChange = ((currentDAU - prevDAU) / prevDAU) * 100
}
crashChange := 0.0
if prevCrashes > 0 {
crashChange = ((float64(currentCrashes) - float64(prevCrashes)) / float64(prevCrashes)) * 100
}
crashFreeRate := 100.0
if totalSessions > 0 {
crashFreeRate = (1 - float64(currentCrashes)/float64(totalSessions)) * 100
if crashFreeRate < 0 {
crashFreeRate = 0
}
}
return &AnalyticsOverview{
DAU: int(currentDAU),
DAUChange: dauChange,
TotalCrashes: currentCrashes,
CrashChange: crashChange,
CrashFreeRate: crashFreeRate,
TotalSessions: totalSessions,
}, nil
}
// GetDailyStats returns daily statistics for an app
func (s *Service) GetDailyStats(ctx context.Context, appID, eventType string, days int) ([]DailyStats, error) {
startDate := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
query := `
SELECT app_id, date, event_type, count, unique_devices
FROM daily_stats
WHERE app_id = ? AND date >= ?
`
args := []interface{}{appID, startDate}
if eventType != "" {
query += " AND event_type = ?"
args = append(args, eventType)
}
query += " ORDER BY date ASC"
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var stats []DailyStats
for rows.Next() {
var s DailyStats
if err := rows.Scan(&s.AppID, &s.Date, &s.EventType, &s.Count, &s.UniqueDevices); err != nil {
return nil, err
}
stats = append(stats, s)
}
return stats, rows.Err()
}
// GetCrashGroups returns crash groups for an app
func (s *Service) GetCrashGroups(ctx context.Context, appID, status string, limit, offset int) ([]CrashGroup, int, error) {
// Count total
countQuery := "SELECT COUNT(*) FROM crash_groups WHERE app_id = ?"
args := []interface{}{appID}
if status != "" {
countQuery += " AND status = ?"
args = append(args, status)
}
var total int
if err := s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
return nil, 0, err
}
// Fetch groups
query := `
SELECT id, app_id, fingerprint, crash_type, message, sample_stack_trace,
first_seen, last_seen, occurrence_count, affected_versions, status
FROM crash_groups WHERE app_id = ?
`
args = []interface{}{appID}
if status != "" {
query += " AND status = ?"
args = append(args, status)
}
query += " ORDER BY last_seen DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var groups []CrashGroup
for rows.Next() {
var g CrashGroup
var versionsJSON string
if err := rows.Scan(&g.ID, &g.AppID, &g.Fingerprint, &g.CrashType, &g.Message,
&g.SampleStackTrace, &g.FirstSeen, &g.LastSeen, &g.OccurrenceCount,
&versionsJSON, &g.Status); err != nil {
return nil, 0, err
}
json.Unmarshal([]byte(versionsJSON), &g.AffectedVersions)
groups = append(groups, g)
}
return groups, total, rows.Err()
}
// GetCrashGroup returns a single crash group with recent occurrences
func (s *Service) GetCrashGroup(ctx context.Context, appID, groupID string) (*CrashGroup, error) {
var g CrashGroup
var versionsJSON string
err := s.db.QueryRowContext(ctx, `
SELECT id, app_id, fingerprint, crash_type, message, sample_stack_trace,
first_seen, last_seen, occurrence_count, affected_versions, status
FROM crash_groups WHERE app_id = ? AND id = ?
`, appID, groupID).Scan(&g.ID, &g.AppID, &g.Fingerprint, &g.CrashType, &g.Message,
&g.SampleStackTrace, &g.FirstSeen, &g.LastSeen, &g.OccurrenceCount,
&versionsJSON, &g.Status)
if err != nil {
return nil, err
}
json.Unmarshal([]byte(versionsJSON), &g.AffectedVersions)
return &g, nil
}
// UpdateCrashGroupStatus updates the status of a crash group
func (s *Service) UpdateCrashGroupStatus(ctx context.Context, appID, groupID, status string) error {
_, err := s.db.ExecContext(ctx, `
UPDATE crash_groups SET status = ? WHERE app_id = ? AND id = ?
`, status, appID, groupID)
return err
}
// StartBackgroundWorkers starts the aggregation and cleanup workers
func (s *Service) StartBackgroundWorkers(ctx context.Context) {
// Hourly aggregation
go s.runPeriodic(ctx, time.Hour, "hourly aggregation", s.aggregateHourly)
// Daily aggregation at 2am
go s.runDaily(ctx, 2, "daily aggregation", s.aggregateDaily)
// Cleanup old events at 3am
go s.runDaily(ctx, 3, "event cleanup", s.cleanupOldEvents)
log.Println("Telemetry background workers started")
}
func (s *Service) runPeriodic(ctx context.Context, interval time.Duration, name string, fn func(context.Context) error) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-s.stopCh:
return
case <-ticker.C:
if err := fn(ctx); err != nil {
log.Printf("Telemetry %s error: %v", name, err)
}
}
}
}
func (s *Service) runDaily(ctx context.Context, hour int, name string, fn func(context.Context) error) {
for {
now := time.Now()
next := time.Date(now.Year(), now.Month(), now.Day(), hour, 0, 0, 0, now.Location())
if next.Before(now) {
next = next.Add(24 * time.Hour)
}
wait := next.Sub(now)
select {
case <-ctx.Done():
return
case <-s.stopCh:
return
case <-time.After(wait):
if err := fn(ctx); err != nil {
log.Printf("Telemetry %s error: %v", name, err)
}
}
}
}
func (s *Service) aggregateHourly(ctx context.Context) error {
hour := time.Now().Add(-time.Hour).Format("2006-01-02T15")
_, err := s.db.ExecContext(ctx, `
INSERT OR REPLACE INTO hourly_stats (app_id, hour, event_type, count, unique_devices)
SELECT
app_id,
strftime('%Y-%m-%dT%H', timestamp) as hour,
event_type,
COUNT(*) as count,
COUNT(DISTINCT device_id) as unique_devices
FROM events
WHERE strftime('%Y-%m-%dT%H', timestamp) = ?
GROUP BY app_id, hour, event_type
`, hour)
if err == nil {
log.Printf("Telemetry: hourly aggregation completed for %s", hour)
}
return err
}
func (s *Service) aggregateDaily(ctx context.Context) error {
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
_, err := s.db.ExecContext(ctx, `
INSERT OR REPLACE INTO daily_stats (app_id, date, event_type, count, unique_devices)
SELECT
app_id,
? as date,
event_type,
SUM(count) as count,
SUM(unique_devices) as unique_devices
FROM hourly_stats
WHERE hour LIKE ? || 'T%'
GROUP BY app_id, event_type
`, yesterday, yesterday)
if err == nil {
log.Printf("Telemetry: daily aggregation completed for %s", yesterday)
}
return err
}
func (s *Service) cleanupOldEvents(ctx context.Context) error {
cutoff := time.Now().AddDate(0, 0, -7).Format(time.RFC3339)
result, err := s.db.ExecContext(ctx, "DELETE FROM events WHERE timestamp < ?", cutoff)
if err != nil {
return err
}
deleted, _ := result.RowsAffected()
log.Printf("Telemetry: cleaned up %d old events", deleted)
// Also clean old crash occurrences (90 days)
crashCutoff := time.Now().AddDate(0, 0, -90).Format(time.RFC3339)
s.db.ExecContext(ctx, "DELETE FROM crash_occurrences WHERE timestamp < ?", crashCutoff)
return nil
}
// Helper functions
func generateID() string {
hash := sha256.Sum256([]byte(fmt.Sprintf("%d", time.Now().UnixNano())))
return hex.EncodeToString(hash[:8])
}
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
// TriggerAggregation manually triggers aggregation (useful for testing)
func (s *Service) TriggerAggregation(ctx context.Context) error {
if err := s.aggregateHourly(ctx); err != nil {
return err
}
return s.aggregateDaily(ctx)
}

304
portal/internal/web/docs.go Normal file
View File

@@ -0,0 +1,304 @@
package web
import (
"bytes"
"embed"
"html/template"
"io/fs"
"net/http"
"path"
"strings"
"github.com/yuin/goldmark"
highlighting "github.com/yuin/goldmark-highlighting/v2"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
)
//go:embed docs/*
var docsFS embed.FS
// DocsHandler serves documentation pages
type DocsHandler struct {
md goldmark.Markdown
template *template.Template
docs fs.FS
}
// NewDocsHandler creates a new documentation handler
func NewDocsHandler() (*DocsHandler, error) {
// Configure goldmark with extensions
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM, // GitHub Flavored Markdown
extension.Table,
extension.Strikethrough,
extension.TaskList,
highlighting.NewHighlighting(
highlighting.WithStyle("monokai"),
),
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(
html.WithUnsafe(), // Allow raw HTML
),
)
// Create the page template
tmpl, err := template.New("doc").Parse(docPageTemplate)
if err != nil {
return nil, err
}
// Get embedded docs filesystem
docs, err := fs.Sub(docsFS, "docs")
if err != nil {
return nil, err
}
return &DocsHandler{
md: md,
template: tmpl,
docs: docs,
}, nil
}
// ServeHTTP handles documentation requests
func (h *DocsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Get the requested path
docPath := strings.TrimPrefix(r.URL.Path, "/docs")
if docPath == "" || docPath == "/" {
docPath = "/index"
}
// Clean the path and add .md extension
docPath = path.Clean(docPath)
if !strings.HasSuffix(docPath, ".md") {
docPath = docPath + ".md"
}
docPath = strings.TrimPrefix(docPath, "/")
// Read the markdown file
content, err := fs.ReadFile(h.docs, docPath)
if err != nil {
// Try index.md in directory
if !strings.HasSuffix(docPath, "/index.md") {
dirPath := strings.TrimSuffix(docPath, ".md") + "/index.md"
content, err = fs.ReadFile(h.docs, dirPath)
}
if err != nil {
http.NotFound(w, r)
return
}
}
// Extract title from first heading
title := extractTitle(content)
if title == "" {
title = "Documentation"
}
// Convert markdown to HTML
var buf bytes.Buffer
if err := h.md.Convert(content, &buf); err != nil {
http.Error(w, "Failed to render documentation", http.StatusInternalServerError)
return
}
// Build navigation
nav := h.buildNavigation(docPath)
// Render page
data := docPageData{
Title: title,
Content: template.HTML(buf.String()),
Navigation: nav,
CurrentPath: "/" + strings.TrimSuffix(docPath, ".md"),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.template.Execute(w, data); err != nil {
http.Error(w, "Template error", http.StatusInternalServerError)
}
}
type docPageData struct {
Title string
Content template.HTML
Navigation []navSection
CurrentPath string
}
type navSection struct {
Title string
Items []navItem
}
type navItem struct {
Title string
Path string
Active bool
}
// buildNavigation creates the documentation navigation structure
func (h *DocsHandler) buildNavigation(currentPath string) []navSection {
currentPath = "/" + strings.TrimSuffix(currentPath, ".md")
return []navSection{
{
Title: "Getting Started",
Items: []navItem{
{Title: "Introduction", Path: "/docs", Active: currentPath == "/index"},
{Title: "Quick Start", Path: "/docs/getting-started", Active: currentPath == "/getting-started"},
{Title: "FAQ", Path: "/docs/faq", Active: currentPath == "/faq"},
},
},
{
Title: "Guides",
Items: []navItem{
{Title: "UI Design", Path: "/docs/guides/ui-design", Active: currentPath == "/guides/ui-design"},
{Title: "Lua Scripting", Path: "/docs/guides/lua-scripting", Active: currentPath == "/guides/lua-scripting"},
{Title: "Permissions", Path: "/docs/guides/permissions", Active: currentPath == "/guides/permissions"},
{Title: "Best Practices", Path: "/docs/guides/best-practices", Active: currentPath == "/guides/best-practices"},
},
},
{
Title: "Reference",
Items: []navItem{
{Title: "Lua API", Path: "/docs/api/lua-api", Active: currentPath == "/api/lua-api"},
{Title: "Manifest", Path: "/docs/api/manifest", Active: currentPath == "/api/manifest"},
{Title: "CLI", Path: "/docs/cli", Active: currentPath == "/cli"},
},
},
{
Title: "Help",
Items: []navItem{
{Title: "Troubleshooting", Path: "/docs/troubleshooting", Active: currentPath == "/troubleshooting"},
},
},
}
}
// extractTitle extracts the first H1 heading from markdown
func extractTitle(content []byte) string {
lines := bytes.Split(content, []byte("\n"))
for _, line := range lines {
line = bytes.TrimSpace(line)
if bytes.HasPrefix(line, []byte("# ")) {
return string(bytes.TrimPrefix(line, []byte("# ")))
}
}
return ""
}
// docPageTemplate is the HTML template for documentation pages
const docPageTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} - Mosis Docs</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* Code highlighting */
pre {
background-color: #1e1e2e;
border-radius: 8px;
padding: 16px;
overflow-x: auto;
margin: 16px 0;
}
code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 14px;
}
:not(pre) > code {
background-color: #1e1e2e;
padding: 2px 6px;
border-radius: 4px;
color: #f8f8f2;
}
/* Typography */
.prose h1 { font-size: 2rem; font-weight: 700; margin-top: 2rem; margin-bottom: 1rem; color: #f8fafc; }
.prose h2 { font-size: 1.5rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.75rem; color: #f8fafc; border-bottom: 1px solid #334155; padding-bottom: 0.5rem; }
.prose h3 { font-size: 1.25rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.5rem; color: #f8fafc; }
.prose h4 { font-size: 1rem; font-weight: 600; margin-top: 1rem; margin-bottom: 0.5rem; color: #f8fafc; }
.prose p { margin-bottom: 1rem; line-height: 1.7; }
.prose a { color: #38bdf8; text-decoration: none; }
.prose a:hover { text-decoration: underline; }
.prose ul, .prose ol { margin-bottom: 1rem; padding-left: 1.5rem; }
.prose li { margin-bottom: 0.5rem; }
.prose ul { list-style-type: disc; }
.prose ol { list-style-type: decimal; }
.prose table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
.prose th, .prose td { border: 1px solid #334155; padding: 8px 12px; text-align: left; }
.prose th { background-color: #1e293b; font-weight: 600; }
.prose blockquote { border-left: 4px solid #38bdf8; padding-left: 1rem; margin: 1rem 0; color: #94a3b8; }
.prose hr { border: none; border-top: 1px solid #334155; margin: 2rem 0; }
.prose strong { color: #f8fafc; }
/* Task lists */
.prose input[type="checkbox"] { margin-right: 8px; }
</style>
</head>
<body class="bg-slate-900 text-slate-300 min-h-screen">
<!-- Header -->
<header class="bg-slate-800 border-b border-slate-700 sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
<a href="/docs" class="text-xl font-bold text-white flex items-center gap-2">
<svg class="w-8 h-8 text-sky-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
Mosis Docs
</a>
<nav class="flex items-center gap-6">
<a href="/" class="text-slate-300 hover:text-white transition">Home</a>
<a href="/dashboard" class="text-slate-300 hover:text-white transition">Dashboard</a>
<a href="https://github.com/omixlab/mosis" class="text-slate-300 hover:text-white transition">GitHub</a>
</nav>
</div>
</header>
<div class="max-w-7xl mx-auto flex">
<!-- Sidebar -->
<aside class="w-64 flex-shrink-0 border-r border-slate-700 min-h-[calc(100vh-73px)] sticky top-[73px] self-start hidden lg:block">
<nav class="p-4 space-y-6">
{{range .Navigation}}
<div>
<h3 class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">{{.Title}}</h3>
<ul class="space-y-1">
{{range .Items}}
<li>
<a href="{{.Path}}" class="block px-3 py-2 rounded-md text-sm transition {{if .Active}}bg-sky-500/10 text-sky-400{{else}}text-slate-300 hover:bg-slate-800{{end}}">
{{.Title}}
</a>
</li>
{{end}}
</ul>
</div>
{{end}}
</nav>
</aside>
<!-- Main content -->
<main class="flex-1 min-w-0">
<article class="prose max-w-4xl mx-auto px-8 py-12">
{{.Content}}
</article>
<!-- Footer -->
<footer class="border-t border-slate-700 px-8 py-6 mt-12">
<div class="max-w-4xl mx-auto flex items-center justify-between text-sm text-slate-400">
<p>&copy; 2024 OmixLab LTD. All rights reserved.</p>
<div class="flex gap-4">
<a href="/privacy" class="hover:text-white transition">Privacy</a>
<a href="/terms" class="hover:text-white transition">Terms</a>
</div>
</div>
</footer>
</main>
</div>
</body>
</html>`

View File

@@ -0,0 +1,806 @@
# Lua API Reference
Complete reference for the Mosis Lua API available to apps.
## Global Objects
### document
The current RML document. Use to query and modify UI elements.
```lua
-- Get element by ID
local elem = document:GetElementById("my-id")
-- Get elements by tag
local buttons = document:GetElementsByTagName("button")
-- Get elements by class
local cards = document:GetElementsByClassName("card")
```
### event
Available in event handler functions. Contains information about the triggering event.
```lua
function handleClick(event)
local target = event:GetCurrentElement()
local eventType = event.type
end
```
## Document Methods
### GetElementById(id)
Returns the element with the specified ID, or `nil` if not found.
```lua
local element = document:GetElementById("username-input")
if element then
element.inner_rml = "Found!"
end
```
### GetElementsByTagName(tag)
Returns a table of all elements with the specified tag name.
```lua
local buttons = document:GetElementsByTagName("button")
for i, btn in ipairs(buttons) do
btn:SetClass("styled", true)
end
```
### GetElementsByClassName(class)
Returns a table of all elements with the specified class name.
```lua
local items = document:GetElementsByClassName("list-item")
```
### CreateElement(tag)
Creates a new element with the specified tag name.
```lua
local div = document:CreateElement("div")
div.inner_rml = "New element"
parent:AppendChild(div)
```
### CreateTextNode(text)
Creates a text node with the specified content.
```lua
local text = document:CreateTextNode("Hello")
element:AppendChild(text)
```
## Element Properties
### inner_rml
Gets or sets the inner RML content of an element.
```lua
-- Get content
local content = element.inner_rml
-- Set content (parses RML)
element.inner_rml = "<strong>Bold text</strong>"
```
### id
Gets or sets the element's ID.
```lua
local id = element.id
element.id = "new-id"
```
### style
Access to the element's inline styles.
```lua
element.style.width = "100dp"
element.style.backgroundColor = "#ff0000"
element.style.display = "none"
```
### parent_node
Returns the parent element, or `nil` if none.
```lua
local parent = element.parent_node
```
### first_child / last_child
Returns the first or last child element.
```lua
local first = container.first_child
local last = container.last_child
```
### next_sibling / previous_sibling
Returns the next or previous sibling element.
```lua
local next = element.next_sibling
```
### child_nodes
Returns a table of all child elements.
```lua
local children = element.child_nodes
for i, child in ipairs(children) do
print(child.id)
end
```
### tag_name
Returns the element's tag name (lowercase).
```lua
local tag = element.tag_name -- "div", "button", etc.
```
### offset_width / offset_height
Returns the rendered dimensions of the element.
```lua
local width = element.offset_width
local height = element.offset_height
```
### offset_left / offset_top
Returns the position relative to the offset parent.
```lua
local x = element.offset_left
local y = element.offset_top
```
## Element Methods
### GetAttribute(name)
Returns the value of the specified attribute.
```lua
local value = input:GetAttribute("value")
local placeholder = input:GetAttribute("placeholder")
```
### SetAttribute(name, value)
Sets the value of the specified attribute.
```lua
input:SetAttribute("placeholder", "Enter text...")
button:SetAttribute("disabled", "disabled")
```
### RemoveAttribute(name)
Removes the specified attribute.
```lua
button:RemoveAttribute("disabled")
```
### HasAttribute(name)
Returns `true` if the element has the specified attribute.
```lua
if button:HasAttribute("disabled") then
print("Button is disabled")
end
```
### SetClass(name, add)
Adds or removes a class from the element.
```lua
-- Add class
element:SetClass("active", true)
-- Remove class
element:SetClass("active", false)
```
### IsClassSet(name)
Returns `true` if the element has the specified class.
```lua
if element:IsClassSet("selected") then
print("Element is selected")
end
```
### AppendChild(element)
Appends a child element.
```lua
local child = document:CreateElement("div")
parent:AppendChild(child)
```
### InsertBefore(element, reference)
Inserts an element before the reference element.
```lua
parent:InsertBefore(newElement, referenceElement)
```
### RemoveChild(element)
Removes a child element.
```lua
parent:RemoveChild(childElement)
```
### Focus()
Sets focus to the element.
```lua
input:Focus()
```
### Blur()
Removes focus from the element.
```lua
input:Blur()
```
### Click()
Simulates a click on the element.
```lua
button:Click()
```
### ScrollIntoView(alignToTop)
Scrolls the element into view.
```lua
element:ScrollIntoView(true) -- align to top
element:ScrollIntoView(false) -- align to bottom
```
### AddEventListener(event, handler)
Adds an event listener to the element.
```lua
button:AddEventListener("click", function(event)
print("Clicked!")
end)
```
### RemoveEventListener(event, handler)
Removes an event listener from the element.
```lua
local handler = function(event) print("Click") end
button:AddEventListener("click", handler)
button:RemoveEventListener("click", handler)
```
## Event Object
### type
The event type string (e.g., "click", "change").
```lua
if event.type == "click" then
-- handle click
end
```
### target_element
The element that originally triggered the event.
```lua
local target = event.target_element
```
### current_element
The element the event handler is attached to.
```lua
local current = event.current_element
```
### GetCurrentElement()
Returns the current element (same as `current_element`).
```lua
local elem = event:GetCurrentElement()
```
### StopPropagation()
Stops the event from bubbling up to parent elements.
```lua
event:StopPropagation()
```
### StopImmediatePropagation()
Stops the event and prevents other handlers on the same element.
```lua
event:StopImmediatePropagation()
```
### parameters
Table containing event-specific parameters.
```lua
-- Mouse events
local x = event.parameters.mouse_x
local y = event.parameters.mouse_y
local button = event.parameters.button -- 0=left, 1=right, 2=middle
-- Keyboard events
local key = event.parameters.key_identifier
local ctrl = event.parameters.ctrl_key
local shift = event.parameters.shift_key
local alt = event.parameters.alt_key
```
## Navigation
### navigateTo(screen)
Navigates to a screen, pushing to history.
```lua
navigateTo("settings") -- loads assets/settings.rml
navigateTo("screens/profile") -- loads assets/screens/profile.rml
```
### goBack()
Navigates back to the previous screen.
```lua
goBack()
```
### goHome()
Navigates to the home screen, clearing history.
```lua
goHome()
```
### replaceTo(screen)
Replaces current screen without adding to history.
```lua
replaceTo("login") -- no back navigation possible
```
### canGoBack()
Returns `true` if there's a previous screen in history.
```lua
if canGoBack() then
backButton.style.display = "block"
else
backButton.style.display = "none"
end
```
## Timers
### setTimeout(callback, delay)
Executes callback once after delay (milliseconds). Returns timer ID.
```lua
local id = setTimeout(function()
print("Executed after 1 second")
end, 1000)
```
### clearTimeout(id)
Cancels a timeout.
```lua
local id = setTimeout(callback, 1000)
clearTimeout(id)
```
### setInterval(callback, interval)
Executes callback repeatedly. Returns timer ID.
```lua
local id = setInterval(function()
updateClock()
end, 1000)
```
### clearInterval(id)
Cancels an interval.
```lua
clearInterval(intervalId)
```
## Storage
Persistent key-value storage. Data persists between app sessions.
### storage.set(key, value)
Stores a value. Value can be string, number, boolean, or table.
```lua
storage.set("username", "alice")
storage.set("settings", { darkMode = true, fontSize = 16 })
storage.set("highScore", 1000)
```
### storage.get(key)
Retrieves a stored value, or `nil` if not found.
```lua
local username = storage.get("username")
local settings = storage.get("settings")
if settings then
print(settings.darkMode)
end
```
### storage.remove(key)
Removes a stored value.
```lua
storage.remove("tempData")
```
### storage.clear()
Removes all stored values.
```lua
storage.clear()
```
### storage.keys()
Returns a table of all storage keys.
```lua
local keys = storage.keys()
for i, key in ipairs(keys) do
print(key)
end
```
## HTTP (requires `network` permission)
### http.get(url, callback)
Makes a GET request.
```lua
http.get("https://api.example.com/data", function(response)
if response.ok then
local data = json.decode(response.body)
print(data.message)
else
print("Error: " .. response.status)
end
end)
```
### http.post(url, options, callback)
Makes a POST request.
```lua
http.post("https://api.example.com/submit", {
headers = {
["Content-Type"] = "application/json",
["Authorization"] = "Bearer token123"
},
body = json.encode({ name = "test" })
}, function(response)
print("Status: " .. response.status)
end)
```
### http.request(options, callback)
Makes a custom HTTP request.
```lua
http.request({
method = "PUT",
url = "https://api.example.com/resource/1",
headers = { ["Content-Type"] = "application/json" },
body = json.encode({ updated = true }),
timeout = 5000 -- milliseconds
}, function(response)
print(response.status)
end)
```
### Response Object
| Property | Type | Description |
|----------|------|-------------|
| `ok` | boolean | `true` if status is 200-299 |
| `status` | number | HTTP status code |
| `statusText` | string | Status message |
| `headers` | table | Response headers |
| `body` | string | Response body |
## JSON
### json.encode(value)
Converts a Lua value to a JSON string.
```lua
local str = json.encode({
name = "Alice",
items = {"a", "b", "c"},
count = 3
})
-- '{"name":"Alice","items":["a","b","c"],"count":3}'
```
### json.decode(str)
Parses a JSON string into a Lua value.
```lua
local data = json.decode('{"name":"Alice","age":25}')
print(data.name) -- "Alice"
print(data.age) -- 25
```
## Logging
### print(...)
Outputs to the debug console. Accepts multiple arguments.
```lua
print("Debug message")
print("Value:", someValue, "Count:", count)
```
### console.log(...)
Alias for `print()`.
```lua
console.log("Hello")
```
### console.warn(...)
Logs a warning message.
```lua
console.warn("Something might be wrong")
```
### console.error(...)
Logs an error message.
```lua
console.error("Something went wrong:", errorMessage)
```
## Utility Functions
### tostring(value)
Converts a value to a string.
```lua
local str = tostring(123) -- "123"
```
### tonumber(value)
Converts a value to a number.
```lua
local num = tonumber("123") -- 123
local invalid = tonumber("abc") -- nil
```
### type(value)
Returns the type of a value as a string.
```lua
type("hello") -- "string"
type(123) -- "number"
type(true) -- "boolean"
type({}) -- "table"
type(nil) -- "nil"
type(print) -- "function"
```
### pairs(table)
Iterator for all key-value pairs.
```lua
for key, value in pairs(myTable) do
print(key, value)
end
```
### ipairs(table)
Iterator for array elements (integer keys starting from 1).
```lua
for index, value in ipairs(myArray) do
print(index, value)
end
```
### pcall(func, ...)
Calls a function in protected mode (catches errors).
```lua
local success, result = pcall(function()
return json.decode(maybeInvalidJson)
end)
if success then
print("Parsed:", result)
else
print("Error:", result)
end
```
## Standard Libraries
### string
```lua
string.len(s) -- length
string.upper(s) -- uppercase
string.lower(s) -- lowercase
string.sub(s, i, j) -- substring
string.find(s, pattern) -- find pattern
string.gsub(s, pattern, repl) -- replace
string.match(s, pattern) -- match pattern
string.format(fmt, ...) -- format string
string.byte(s, i) -- character code
string.char(...) -- character from code
string.rep(s, n) -- repeat string
string.reverse(s) -- reverse string
string.split(s, sep) -- split by separator (extension)
string.trim(s) -- trim whitespace (extension)
```
### math
```lua
math.abs(x) -- absolute value
math.ceil(x) -- round up
math.floor(x) -- round down
math.round(x) -- round to nearest (extension)
math.max(...) -- maximum
math.min(...) -- minimum
math.sqrt(x) -- square root
math.pow(x, y) -- power
math.exp(x) -- e^x
math.log(x) -- natural log
math.sin(x) -- sine
math.cos(x) -- cosine
math.tan(x) -- tangent
math.asin(x) -- arc sine
math.acos(x) -- arc cosine
math.atan(x) -- arc tangent
math.atan2(y, x) -- arc tangent of y/x
math.deg(x) -- radians to degrees
math.rad(x) -- degrees to radians
math.random() -- random 0-1
math.random(n) -- random 1-n
math.random(m, n) -- random m-n
math.randomseed(x) -- set random seed
math.pi -- 3.14159...
math.huge -- infinity
```
### table
```lua
table.insert(t, value) -- append
table.insert(t, pos, value) -- insert at position
table.remove(t) -- remove last
table.remove(t, pos) -- remove at position
table.sort(t) -- sort ascending
table.sort(t, comp) -- sort with comparator
table.concat(t, sep) -- join to string
table.unpack(t) -- unpack to values (extension)
table.pack(...) -- pack values to table (extension)
```
### os
```lua
os.time() -- current timestamp
os.time(t) -- timestamp from table
os.date() -- current date string
os.date(format) -- formatted date
os.date(format, t) -- formatted date for timestamp
os.date("*t") -- date as table
os.difftime(t2, t1) -- time difference
os.clock() -- CPU time used
```
Date format codes:
- `%Y` - 4-digit year
- `%m` - month (01-12)
- `%d` - day (01-31)
- `%H` - hour (00-23)
- `%M` - minute (00-59)
- `%S` - second (00-59)
- `%a` - abbreviated weekday
- `%A` - full weekday
- `%b` - abbreviated month
- `%B` - full month
## See Also
- [Lua Scripting Guide](../guides/lua-scripting.md) - Tutorials and examples
- [Permissions Guide](../guides/permissions.md) - Permission system
- [UI Design Guide](../guides/ui-design.md) - RML/RCSS reference

View File

@@ -0,0 +1,341 @@
# Manifest Reference
Every Mosis app requires a `manifest.json` file in the root of the package. This file describes your app and its requirements.
## Complete Schema
```json
{
"id": "com.example.myapp",
"name": "My App",
"version": "1.0.0",
"version_code": 1,
"entry": "assets/main.rml",
"permissions": [],
"min_mosis_version": "1.0.0",
"author": {
"name": "Developer Name",
"email": "dev@example.com",
"url": "https://example.com"
},
"icons": {
"32": "icons/icon-32.png",
"64": "icons/icon-64.png",
"128": "icons/icon-128.png"
},
"description": "A short description of your app",
"category": "utilities",
"screenshots": [
"screenshots/1.png",
"screenshots/2.png"
],
"locales": {
"default": "en",
"supported": ["en", "es", "fr"]
}
}
```
## Required Fields
### id
**Type:** `string`
Unique package identifier in reverse domain notation. Must match the ID registered in the developer portal.
```json
"id": "com.yourcompany.appname"
```
Rules:
- Lowercase letters, numbers, and periods only
- Must have at least two segments (e.g., `com.app`)
- Maximum 255 characters
- Cannot start or end with a period
### name
**Type:** `string`
Display name shown to users. Maximum 50 characters.
```json
"name": "My Awesome App"
```
### version
**Type:** `string`
Human-readable version string following semantic versioning (MAJOR.MINOR.PATCH).
```json
"version": "1.0.0"
"version": "2.1.3-beta"
```
### version_code
**Type:** `integer`
Numeric version code that must increase with each release. Used to determine if an update is available.
```json
"version_code": 1
```
Rules:
- Must be a positive integer
- Must be greater than all previously published versions
- Maximum value: 2147483647
### entry
**Type:** `string`
Path to the main RML file, relative to package root.
```json
"entry": "assets/main.rml"
```
### author
**Type:** `object`
Information about the app developer.
```json
"author": {
"name": "Developer Name",
"email": "dev@example.com",
"url": "https://example.com"
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | Yes | Developer or company name |
| `email` | string | Yes | Contact email |
| `url` | string | No | Website URL |
### icons
**Type:** `object`
App icons at various sizes. At minimum, provide a 128px icon.
```json
"icons": {
"32": "icons/icon-32.png",
"64": "icons/icon-64.png",
"128": "icons/icon-128.png"
}
```
Supported sizes: 32, 64, 128, 256, 512
Requirements:
- PNG format recommended
- Square aspect ratio
- No transparency on edges (for proper display)
## Optional Fields
### description
**Type:** `string`
Short description shown in app listings. Maximum 200 characters.
```json
"description": "A simple calculator for everyday math."
```
### permissions
**Type:** `array<string>`
List of permissions your app requires. Apps cannot access restricted features without declaring permissions.
```json
"permissions": ["storage", "network"]
```
See [Permissions](#permissions-reference) below.
### min_mosis_version
**Type:** `string`
Minimum Mosis version required to run this app.
```json
"min_mosis_version": "1.0.0"
```
If omitted, defaults to `"1.0.0"`.
### category
**Type:** `string`
App store category for discovery.
```json
"category": "productivity"
```
Valid categories:
- `games`
- `entertainment`
- `productivity`
- `utilities`
- `social`
- `communication`
- `lifestyle`
- `education`
- `health`
- `finance`
- `news`
- `other`
### screenshots
**Type:** `array<string>`
Paths to screenshot images for app store listing.
```json
"screenshots": [
"screenshots/home.png",
"screenshots/settings.png",
"screenshots/detail.png"
]
```
Requirements:
- PNG format
- 1080x1920 (9:16 portrait) recommended
- Maximum 5 screenshots
### locales
**Type:** `object`
Internationalization configuration.
```json
"locales": {
"default": "en",
"supported": ["en", "es", "fr", "de", "ja"]
}
```
| Field | Type | Description |
|-------|------|-------------|
| `default` | string | Default locale code |
| `supported` | array | List of supported locale codes |
Locale files should be placed in `locales/{code}.json`.
## Permissions Reference
| Permission | Description | Example Use |
|------------|-------------|-------------|
| `storage` | Persist data locally | Save user preferences |
| `network` | Make HTTP requests | Fetch remote data |
| `clipboard` | Read/write clipboard | Copy text |
| `notifications` | Show notifications | Reminders |
| `camera` | Access device camera | Photo capture |
| `location` | Get device location | Maps, weather |
| `contacts` | Read contacts | Contact picker |
| `microphone` | Record audio | Voice notes |
### Permission Declaration
```json
"permissions": [
"storage",
"network"
]
```
Users are informed of permissions before installing. Request only what you need.
## Validation
The package builder validates your manifest. Common errors:
| Error | Cause | Solution |
|-------|-------|----------|
| `Invalid package ID` | ID doesn't match pattern | Use `com.company.app` format |
| `Missing required field` | Required field omitted | Add the field |
| `Invalid version_code` | Not a positive integer | Use positive number |
| `Icon not found` | Icon path doesn't exist | Check file paths |
| `Invalid permission` | Unknown permission | Use valid permission name |
## Example: Minimal Manifest
```json
{
"id": "com.example.hello",
"name": "Hello World",
"version": "1.0.0",
"version_code": 1,
"entry": "main.rml",
"author": {
"name": "Developer",
"email": "dev@example.com"
},
"icons": {
"128": "icon.png"
}
}
```
## Example: Full Manifest
```json
{
"id": "com.acme.calculator",
"name": "ACME Calculator",
"version": "2.1.0",
"version_code": 5,
"entry": "assets/main.rml",
"description": "A powerful calculator with scientific functions.",
"category": "utilities",
"permissions": [
"storage",
"clipboard"
],
"min_mosis_version": "1.2.0",
"author": {
"name": "ACME Corp",
"email": "apps@acme.com",
"url": "https://acme.com"
},
"icons": {
"32": "icons/icon-32.png",
"64": "icons/icon-64.png",
"128": "icons/icon-128.png",
"256": "icons/icon-256.png"
},
"screenshots": [
"screenshots/basic.png",
"screenshots/scientific.png",
"screenshots/history.png"
],
"locales": {
"default": "en",
"supported": ["en", "es", "fr", "de"]
}
}
```
## Next Steps
- [Getting Started](../getting-started.md) - Create your first app
- [Permissions Guide](../guides/permissions.md) - Understanding permissions
- [Publishing Guide](../guides/publishing.md) - Submit your app

View File

@@ -0,0 +1,576 @@
# CLI Reference
The Mosis CLI (`mosis`) is a command-line tool for building, testing, and publishing Mosis apps.
## Installation
### Windows
Download the installer from your [Developer Dashboard](/dashboard) or use:
```powershell
# Using winget
winget install omixlab.mosis-cli
# Or download directly
curl -o mosis-cli.exe https://dl.omixlab.com/cli/windows/mosis.exe
```
### macOS
```bash
# Using Homebrew
brew install omixlab/tap/mosis-cli
# Or download directly
curl -fsSL https://dl.omixlab.com/cli/macos/mosis > /usr/local/bin/mosis
chmod +x /usr/local/bin/mosis
```
### Linux
```bash
# Download binary
curl -fsSL https://dl.omixlab.com/cli/linux/mosis > ~/.local/bin/mosis
chmod +x ~/.local/bin/mosis
# Or using snap
sudo snap install mosis-cli
```
## Quick Start
```bash
# Create a new project
mosis init myapp
# Build the package
cd myapp
mosis build
# Test locally
mosis run
# Login and publish
mosis login
mosis publish
```
## Commands
### mosis init
Create a new Mosis app project.
```bash
mosis init <name> [options]
```
**Arguments:**
- `name` - Project name (creates directory)
**Options:**
| Option | Description |
|--------|-------------|
| `--template <name>` | Use a starter template |
| `--package-id <id>` | Set package ID |
| `--no-git` | Don't initialize git repo |
**Templates:**
- `default` - Basic app structure
- `minimal` - Bare minimum files
- `navigation` - Multi-screen with navigation
- `form` - Form handling example
- `list` - Scrollable list example
**Example:**
```bash
mosis init myapp --template navigation --package-id com.example.myapp
```
**Output:**
```
myapp/
├── manifest.json
├── icon.png
├── assets/
│ ├── main.rml
│ └── styles.rcss
└── .mosis/
└── config.json
```
---
### mosis build
Build a `.mosis` package from your project.
```bash
mosis build [options]
```
**Options:**
| Option | Description |
|--------|-------------|
| `-o, --output <path>` | Output file path |
| `--no-sign` | Skip signing (dev only) |
| `--verbose` | Show detailed output |
| `--validate-only` | Validate without building |
**Example:**
```bash
mosis build -o dist/myapp.mosis
```
**Build Process:**
1. Validates manifest.json
2. Checks all referenced files exist
3. Validates RML/RCSS syntax
4. Creates compressed package
5. Signs with developer key (if available)
---
### mosis validate
Validate your project without building.
```bash
mosis validate [options]
```
**Options:**
| Option | Description |
|--------|-------------|
| `--strict` | Enable strict validation |
| `--fix` | Auto-fix simple issues |
**Checks performed:**
- Manifest schema validation
- Required files existence
- Icon sizes and formats
- RML/RCSS syntax
- Lua syntax
- Package size limits
**Example output:**
```
✓ manifest.json is valid
✓ All required icons present
✓ Entry point exists: assets/main.rml
✓ RML syntax valid (3 files)
✓ RCSS syntax valid (2 files)
✓ Lua syntax valid (1 file)
✓ Package size: 45KB (under 10MB limit)
Validation passed!
```
---
### mosis run
Run your app in the local designer for testing.
```bash
mosis run [options]
```
**Options:**
| Option | Description |
|--------|-------------|
| `--device <name>` | Target device profile |
| `--scale <factor>` | Window scale factor |
| `--hot-reload` | Enable hot reload (default) |
| `--no-hot-reload` | Disable hot reload |
**Device profiles:**
- `phone` - Standard phone (1080x1920)
- `tablet` - Tablet (1200x1920)
- `watch` - Watch (360x360)
**Example:**
```bash
mosis run --device phone --scale 0.5
```
---
### mosis login
Authenticate with the developer portal.
```bash
mosis login [options]
```
**Options:**
| Option | Description |
|--------|-------------|
| `--token <token>` | Use API token directly |
| `--browser` | Open browser for OAuth |
**Interactive login:**
```bash
$ mosis login
Opening browser for authentication...
✓ Logged in as developer@example.com
```
**Token login (for CI/CD):**
```bash
mosis login --token YOUR_API_TOKEN
```
---
### mosis logout
Log out of the developer portal.
```bash
mosis logout
```
---
### mosis publish
Upload and submit your app for review.
```bash
mosis publish [options]
```
**Options:**
| Option | Description |
|--------|-------------|
| `--notes <text>` | Release notes |
| `--notes-file <path>` | Release notes from file |
| `--draft` | Upload as draft (don't submit) |
| `--track <name>` | Release track (production/beta) |
**Example:**
```bash
mosis publish --notes "Bug fixes and performance improvements"
```
**Process:**
1. Builds package (if needed)
2. Uploads to portal
3. Runs automated validation
4. Submits for review (unless `--draft`)
---
### mosis status
Check the status of your app submissions.
```bash
mosis status [app-id]
```
**Example output:**
```
com.example.myapp
Latest Version: 1.2.0 (code: 5)
Status: In Review
Submitted: 2 hours ago
Previous Versions:
1.1.0 (4) - Published
1.0.0 (1) - Published
```
---
### mosis keys
Manage signing keys.
```bash
mosis keys <subcommand>
```
**Subcommands:**
#### keys generate
Generate a new signing keypair.
```bash
mosis keys generate [options]
```
| Option | Description |
|--------|-------------|
| `-o, --output <path>` | Output directory |
| `--name <name>` | Key name |
```bash
$ mosis keys generate --name production
Generated keypair:
Private: ~/.mosis/keys/production.key
Public: ~/.mosis/keys/production.pub
Keep your private key secure! Never share it.
```
#### keys register
Upload your public key to the portal.
```bash
mosis keys register <key-file>
```
```bash
$ mosis keys register ~/.mosis/keys/production.pub
✓ Key registered successfully
Key ID: k_abc123xyz
Algorithm: Ed25519
```
#### keys list
List registered keys.
```bash
$ mosis keys list
ID Name Created Status
k_abc123xyz production 2024-01-15 Active
k_def456uvw development 2024-01-10 Active
```
#### keys revoke
Revoke a registered key.
```bash
mosis keys revoke <key-id>
```
---
### mosis config
Manage CLI configuration.
```bash
mosis config <subcommand>
```
**Subcommands:**
#### config get
Get a configuration value.
```bash
mosis config get <key>
```
#### config set
Set a configuration value.
```bash
mosis config set <key> <value>
```
#### config list
List all configuration.
```bash
$ mosis config list
api_url = https://api.omixlab.com
designer_path = /usr/local/bin/mosis-designer
default_key = production
```
**Configuration keys:**
| Key | Description | Default |
|-----|-------------|---------|
| `api_url` | API endpoint | https://api.omixlab.com |
| `designer_path` | Path to designer | (auto-detected) |
| `default_key` | Default signing key | (none) |
| `auto_build` | Build before publish | true |
---
### mosis doctor
Diagnose common issues with your setup.
```bash
$ mosis doctor
Checking Mosis CLI installation...
✓ CLI version: 1.2.0
✓ Designer found: /usr/local/bin/mosis-designer
✓ Authenticated as: developer@example.com
✓ Signing key configured: production
✓ Network connectivity OK
All checks passed!
```
---
### mosis version
Show CLI version information.
```bash
$ mosis version
mosis-cli version 1.2.0
Built: 2024-01-15
Go: 1.21.5
```
---
### mosis help
Show help for any command.
```bash
mosis help [command]
mosis <command> --help
```
## Environment Variables
| Variable | Description |
|----------|-------------|
| `MOSIS_API_URL` | Override API endpoint |
| `MOSIS_TOKEN` | API token for authentication |
| `MOSIS_KEY_PATH` | Path to signing key |
| `MOSIS_NO_COLOR` | Disable colored output |
| `MOSIS_DEBUG` | Enable debug logging |
## Configuration Files
### Global Config
Location: `~/.mosis/config.json`
```json
{
"api_url": "https://api.omixlab.com",
"default_key": "production",
"auto_build": true
}
```
### Project Config
Location: `.mosis/config.json` (in project root)
```json
{
"signing_key": "production",
"build_output": "dist/"
}
```
## Exit Codes
| Code | Meaning |
|------|---------|
| 0 | Success |
| 1 | General error |
| 2 | Invalid arguments |
| 3 | Authentication required |
| 4 | Validation failed |
| 5 | Network error |
| 6 | Build failed |
## CI/CD Integration
### GitHub Actions
```yaml
name: Publish Mosis App
on:
push:
tags: ['v*']
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Mosis CLI
run: |
curl -fsSL https://dl.omixlab.com/cli/linux/mosis > mosis
chmod +x mosis
sudo mv mosis /usr/local/bin/
- name: Build and Publish
env:
MOSIS_TOKEN: ${{ secrets.MOSIS_TOKEN }}
run: |
mosis build
mosis publish --notes "Release ${GITHUB_REF#refs/tags/}"
```
### GitLab CI
```yaml
publish:
image: ubuntu:latest
script:
- curl -fsSL https://dl.omixlab.com/cli/linux/mosis > /usr/local/bin/mosis
- chmod +x /usr/local/bin/mosis
- mosis build
- mosis publish --notes "Release $CI_COMMIT_TAG"
only:
- tags
variables:
MOSIS_TOKEN: $MOSIS_TOKEN
```
## Troubleshooting
### "Command not found"
Ensure the CLI is in your PATH:
```bash
echo $PATH
which mosis
```
### "Authentication failed"
Re-login:
```bash
mosis logout
mosis login
```
### "Build failed: Invalid manifest"
Run validation for details:
```bash
mosis validate --strict
```
### "Network error"
Check connectivity:
```bash
mosis doctor
curl -I https://api.omixlab.com/health
```
## See Also
- [Getting Started](getting-started.md) - First app tutorial
- [Publishing Guide](guides/publishing.md) - Submission tips
- [API Reference](api/lua-api.md) - Lua API documentation

View File

@@ -0,0 +1,267 @@
# Frequently Asked Questions
## General
### What is Mosis?
Mosis is a virtual smartphone OS for VR games and applications. It provides a phone-like device that users can interact with inside VR environments, complete with real smartphone functionality.
### Who can develop apps for Mosis?
Anyone! Sign up for a free developer account to start building apps. There's no fee to register or submit apps.
### What can I build with Mosis?
You can build any app that works on a phone screen:
- Utilities (calculators, converters, timers)
- Productivity (notes, to-do lists, calendars)
- Games (puzzles, casual games)
- Entertainment (media players, readers)
- Social apps (chat, messaging)
- And more!
### How do users get my app?
Users discover and install apps through the Mosis App Store, which is built into the virtual phone. Published apps appear in store listings where users can browse, search, and install.
## Development
### What languages/technologies do I need to know?
Mosis apps use:
- **RML** - Similar to HTML for structure
- **RCSS** - Similar to CSS for styling
- **Lua** - Lightweight scripting language for logic
If you know HTML/CSS, you'll find RML/RCSS very familiar. Lua is simple to learn and has many tutorials available.
### Can I use JavaScript instead of Lua?
No, Mosis uses Lua for scripting. Lua was chosen for its:
- Lightweight footprint
- Easy sandboxing for security
- Simple learning curve
- Fast execution
### Can I use React/Vue/Angular?
No, Mosis uses its own RML/RCSS system based on RmlUi. Standard web frameworks won't work, but the concepts are similar enough that web developers can adapt quickly.
### What IDEs are supported?
Use any text editor! VS Code is recommended with these extensions:
- Lua Language Server
- XML/HTML tools for RML editing
### Is there a visual designer?
The Desktop Designer provides:
- Live preview of your app
- Hot reload on file changes
- Hierarchy inspection
- Screenshot capture
It's included in your developer tools download.
### Can I test on a real device?
Yes! You can:
1. Install the Designer on your PC
2. Build a .mosis package
3. Sideload onto a VR device with MosisService installed
### How large can my app be?
The maximum package size is **10MB**. This is plenty for most apps. If you need more:
- Optimize images (use TGA format)
- Remove unused assets
- Load large data from the network
### Can I use external APIs?
Yes, with the `network` permission. Make HTTPS requests to any API:
```lua
http.get("https://api.example.com/data", function(response)
local data = json.decode(response.body)
end)
```
### Can I access the device camera/microphone?
Yes, with the appropriate permissions:
- `camera` - For photo capture
- `microphone` - For audio recording
Users will be prompted to grant access.
### Can my app run in the background?
Currently, apps only run when visible. Background execution is planned for future versions.
### Can I access native device features?
Mosis apps are sandboxed for security. Available device features:
- Storage
- Network
- Camera (with permission)
- Microphone (with permission)
- Clipboard
- Notifications
Direct hardware access (Bluetooth, USB, etc.) is not available.
## Publishing
### How long does review take?
Most apps are reviewed within 24-48 hours. Apps requesting sensitive permissions may take longer.
Automated checks run instantly. Manual review is triggered for:
- First-time developers
- Sensitive permissions
- Flagged content
### Why was my app rejected?
Check the rejection reason in your dashboard. Common reasons:
- Crashes on launch
- Missing required assets
- Policy violations
- Inappropriate content
- Misleading metadata
### Can I update my app?
Yes! Submit a new version with:
- Higher `version_code`
- Updated `version` string
- Release notes
Updates go through the same review process.
### Can I remove my app from the store?
Yes, go to your app's settings and choose "Unpublish". Users who installed it can keep using it, but it won't appear in searches.
### Can I have paid apps?
Currently, all apps are free. Paid apps and in-app purchases are planned for future versions.
### What's the revenue share?
When monetization launches, the split will be:
- **70%** to developers
- **30%** to Mosis platform
### Can I distribute outside the store?
Yes, you can share `.mosis` files directly. However:
- Users must enable sideloading
- Updates won't be automatic
- No store discoverability
## Technical
### What RML/RCSS version is supported?
Mosis uses RmlUi 6.x. The [UI Design Guide](guides/ui-design.md) covers supported features. Not all CSS3 features are available.
### What Lua version is supported?
Lua 5.4 with some restrictions for sandboxing. See the [Lua Scripting Guide](guides/lua-scripting.md) for details.
### Are there size limits for storage?
Each app has 5MB of local storage. For more data, use network storage.
### Can I use databases?
Use the `storage` API for key-value storage. SQLite is not directly available, but you can:
- Store JSON data
- Use a remote database via network
### How do I handle different screen sizes?
Design for the standard phone screen (1080x1920 logical pixels). Use:
- `dp` units for consistent sizing
- Flexbox for flexible layouts
- Percentage widths for adaptability
### Can I create multiple screens?
Yes, use the navigation system:
```lua
navigateTo("settings") -- Load settings.rml
goBack() -- Return to previous screen
```
### Can apps communicate with each other?
Currently, apps are isolated. Inter-app communication is planned for future versions.
### What happens if my app crashes?
Crashes are caught by the sandbox. The user sees an error message and can restart. Crash reports are sent to your analytics dashboard (if telemetry is enabled).
### Can I access the file system?
No direct file system access. Use:
- `storage` API for persisted data
- Bundled assets for static files
- `http` API for remote files
## Account & Legal
### Is there a developer fee?
No, developer accounts are free. There's no cost to register, develop, or publish apps.
### Can I transfer my app to another developer?
Contact support to request a transfer.
### What content is not allowed?
- Malware or security exploits
- Hate speech or discrimination
- Adult content (unless properly rated)
- Copyright infringement
- Privacy violations
- Impersonation of other apps/brands
See the full content policy in your developer agreement.
### Do I need a privacy policy?
You need a privacy policy if your app:
- Collects user data
- Uses analytics
- Makes network requests
- Accesses contacts, location, etc.
### Who owns the IP for my app?
You retain all intellectual property rights to your app. By publishing on Mosis, you grant a license to distribute it through the store.
### Can I use open source code?
Yes, but respect the licenses:
- MIT, BSD, Apache: Generally safe
- GPL: May require source distribution
- Proprietary: Check terms carefully
## More Questions?
If your question isn't answered here:
1. Check the [Troubleshooting](troubleshooting.md) guide
2. Search the developer forum
3. Contact support through your dashboard
## See Also
- [Getting Started](getting-started.md) - Create your first app
- [Troubleshooting](troubleshooting.md) - Common problems and solutions
- [API Reference](api/lua-api.md) - Complete API documentation

View File

@@ -0,0 +1,190 @@
# Getting Started
This guide walks you through creating your first Mosis app in under 10 minutes.
## Prerequisites
- A Mosis developer account ([sign up here](/register))
- Text editor (VS Code recommended)
- Desktop Designer for testing (download from portal)
## Step 1: Create a New App
1. Log in to the [Developer Portal](/dashboard)
2. Click **Create New App**
3. Fill in the details:
- **Package ID**: `com.yourname.myapp` (unique identifier)
- **App Name**: My First App
- **Description**: A simple hello world app
4. Click **Create**
## Step 2: Set Up Your Project
Create a project folder with this structure:
```
myapp/
├── manifest.json
├── icon.png
└── assets/
├── main.rml
└── styles.rcss
```
### manifest.json
```json
{
"id": "com.yourname.myapp",
"name": "My First App",
"version": "1.0.0",
"version_code": 1,
"entry": "assets/main.rml",
"permissions": [],
"min_mosis_version": "1.0.0",
"author": {
"name": "Your Name",
"email": "you@example.com"
},
"icons": {
"32": "icon.png",
"64": "icon.png",
"128": "icon.png"
}
}
```
### assets/main.rml
```xml
<rml>
<head>
<title>My First App</title>
<link type="text/rcss" href="styles.rcss"/>
</head>
<body>
<div class="container">
<h1>Hello, Mosis!</h1>
<p>This is my first app.</p>
<button id="click-me" onclick="handleClick()">Click Me</button>
<p id="counter">Clicks: 0</p>
</div>
<script>
local clicks = 0
function handleClick()
clicks = clicks + 1
document:GetElementById("counter").inner_rml = "Clicks: " .. clicks
end
</script>
</body>
</rml>
```
### assets/styles.rcss
```css
body {
font-family: LatoLatin;
background-color: #1a1a2e;
color: #ffffff;
}
.container {
padding: 20dp;
text-align: center;
}
h1 {
font-size: 24dp;
margin-bottom: 10dp;
color: #00d4ff;
}
p {
font-size: 16dp;
margin-bottom: 20dp;
}
button {
background-color: #00d4ff;
color: #1a1a2e;
padding: 12dp 24dp;
border-radius: 8dp;
font-size: 16dp;
font-weight: bold;
}
button:hover {
background-color: #00b8e6;
}
button:active {
background-color: #0099cc;
}
#counter {
font-size: 18dp;
margin-top: 20dp;
}
```
### icon.png
Create a 128x128 PNG icon for your app. Use any image editor or find a placeholder icon.
## Step 3: Test Locally
1. Download and install the Desktop Designer from your dashboard
2. Open a terminal in your project folder
3. Run:
```bash
mosis-designer.exe assets/main.rml
```
The designer window opens showing your app. Changes to RML, RCSS, or Lua files automatically reload.
## Step 4: Build Your Package
From your project folder:
```bash
mosis build
```
This creates `myapp.mosis` - your packaged app ready for submission.
## Step 5: Submit for Review
1. Go to your app in the Developer Portal
2. Click **Create New Version**
3. Upload your `.mosis` package
4. Add release notes
5. Click **Submit for Review**
Your app will be reviewed automatically. If it passes all checks, you can publish it to the store.
## Next Steps
- [UI Design Guide](guides/ui-design.md) - Learn RML/RCSS in depth
- [Lua Scripting Guide](guides/lua-scripting.md) - Add complex interactivity
- [Permissions Guide](guides/permissions.md) - Request device capabilities
- [Publishing Guide](guides/publishing.md) - Tips for successful submissions
## Example Apps
Check out these example apps to learn from:
| App | Description | Source |
|-----|-------------|--------|
| Calculator | Basic calculator | [View](examples/calculator.md) |
| Notes | Simple note-taking | [View](examples/notes.md) |
| Timer | Countdown timer | [View](examples/timer.md) |
## Getting Help
- Join our [Discord community](#)
- Check the [FAQ](faq.md)
- Search the [Troubleshooting guide](troubleshooting.md)

View File

@@ -0,0 +1,535 @@
# Best Practices
Guidelines for building high-quality Mosis apps that users love.
## Performance
### Minimize DOM Queries
Cache element references instead of querying repeatedly:
```lua
-- Bad: Queries on every frame
function updateScore()
document:GetElementById("score").inner_rml = tostring(score)
end
-- Good: Cache the reference
local scoreElement
function onLoad()
scoreElement = document:GetElementById("score")
end
function updateScore()
scoreElement.inner_rml = tostring(score)
end
```
### Batch DOM Updates
Group multiple changes together:
```lua
-- Bad: Multiple separate updates
elem1.style.color = "red"
elem2.style.color = "red"
elem3.style.color = "red"
-- Good: Use a class
parent:SetClass("error-state", true)
```
### Use Efficient Data Structures
```lua
-- For frequent lookups, use tables as maps
local itemLookup = {}
for i, item in ipairs(items) do
itemLookup[item.id] = item
end
-- O(1) lookup instead of O(n) search
local item = itemLookup["item-123"]
```
### Clean Up Timers
Always clear intervals when navigating away:
```lua
local updateInterval
function onScreenLoad()
updateInterval = setInterval(function()
updateData()
end, 1000)
end
function onScreenUnload()
if updateInterval then
clearInterval(updateInterval)
updateInterval = nil
end
end
```
### Lazy Load Content
Don't load everything at startup:
```lua
-- Load data when user scrolls to section
function onSectionVisible(sectionId)
if not loadedSections[sectionId] then
loadSectionData(sectionId)
loadedSections[sectionId] = true
end
end
```
## User Experience
### Provide Feedback
Show users that actions are happening:
```lua
function onSubmit()
-- Show loading state immediately
submitButton:SetClass("loading", true)
submitButton:SetAttribute("disabled", "disabled")
http.post(url, data, function(response)
submitButton:SetClass("loading", false)
submitButton:RemoveAttribute("disabled")
if response.ok then
showSuccess("Saved!")
else
showError("Failed to save")
end
end)
end
```
### Handle Errors Gracefully
Never show raw error messages to users:
```lua
http.get(url, function(response)
if response.ok then
displayData(json.decode(response.body))
else
-- User-friendly message
showMessage("Unable to load data. Please check your connection.")
-- Log details for debugging
console.error("API error:", response.status, response.body)
end
end)
```
### Make Touch Targets Large Enough
Minimum 48dp for touchable elements:
```css
.button {
min-width: 48dp;
min-height: 48dp;
padding: 12dp 24dp;
}
.list-item {
min-height: 56dp;
padding: 16dp;
}
```
### Support Undo for Destructive Actions
```lua
local deletedItem = nil
local undoTimeout = nil
function deleteItem(itemId)
deletedItem = items[itemId]
items[itemId] = nil
updateList()
showUndoSnackbar("Item deleted", function()
-- Undo callback
items[itemId] = deletedItem
deletedItem = nil
updateList()
end)
-- Clear undo after 5 seconds
undoTimeout = setTimeout(function()
deletedItem = nil
permanentlyDelete(itemId)
end, 5000)
end
```
### Remember User State
Restore position and selections when returning:
```lua
function onScreenUnload()
storage.set("list_scroll_position", scrollContainer.scroll_top)
storage.set("selected_tab", currentTab)
end
function onScreenLoad()
local scrollPos = storage.get("list_scroll_position")
if scrollPos then
scrollContainer.scroll_top = scrollPos
end
local tab = storage.get("selected_tab")
if tab then
selectTab(tab)
end
end
```
## Code Quality
### Use Local Variables
Local variables are faster and prevent global pollution:
```lua
-- Bad: Global
count = 0
-- Good: Local
local count = 0
-- Good: Module-level local
local Utils = {}
local cache = {} -- Private to module
```
### Handle Edge Cases
```lua
function divide(a, b)
if b == 0 then
console.warn("Division by zero")
return 0
end
return a / b
end
function getUsername(user)
if not user then
return "Unknown"
end
return user.name or user.email or "Unknown"
end
```
### Use Meaningful Names
```lua
-- Bad
local t = {}
local n = 0
-- Good
local userScores = {}
local attemptCount = 0
-- Bad
function p(x)
return x * 100
end
-- Good
function toPercentage(decimal)
return decimal * 100
end
```
### Keep Functions Small
Each function should do one thing:
```lua
-- Bad: Does too much
function processUser(userId)
local user = fetchUser(userId)
validateUser(user)
updateUserStats(user)
sendWelcomeEmail(user)
logActivity(user)
return formatUserResponse(user)
end
-- Good: Composed of small functions
function processNewUser(userId)
local user = fetchUser(userId)
if not isValidUser(user) then
return nil, "Invalid user"
end
initializeUserStats(user)
queueWelcomeEmail(user)
return user
end
```
### Comment Why, Not What
```lua
-- Bad: Describes what (obvious from code)
-- Increment counter by 1
counter = counter + 1
-- Good: Explains why
-- Reset retry count after successful connection
-- to prevent unnecessary backoff on next attempt
retryCount = 0
```
## Security
### Validate All Input
```lua
function searchItems(query)
-- Sanitize input
if type(query) ~= "string" then
return {}
end
query = query:sub(1, 100) -- Limit length
query = query:gsub("[^%w%s]", "") -- Remove special chars
return performSearch(query)
end
```
### Don't Trust External Data
```lua
http.get(url, function(response)
local success, data = pcall(function()
return json.decode(response.body)
end)
if not success then
console.error("Invalid JSON from API")
return
end
-- Validate structure
if type(data.items) ~= "table" then
console.error("Missing items array")
return
end
processItems(data.items)
end)
```
### Never Store Secrets in Code
```lua
-- Bad: Hardcoded API key
local API_KEY = "sk-12345abcde"
-- Good: Use environment/config
local apiKey = config.get("api_key")
```
### Sanitize Display Content
When displaying user-generated content, prevent injection:
```lua
function displayComment(text)
-- Escape HTML entities
text = text:gsub("&", "&amp;")
text = text:gsub("<", "&lt;")
text = text:gsub(">", "&gt;")
commentElement.inner_rml = text
end
```
## Accessibility
### Use Semantic Elements
```xml
<!-- Bad: Divs for everything -->
<div class="button" onclick="submit()">Submit</div>
<!-- Good: Proper elements -->
<button onclick="submit()">Submit</button>
<!-- Good: Headings create hierarchy -->
<h1>Settings</h1>
<h2>Account</h2>
<h2>Notifications</h2>
```
### Provide Text Alternatives
```xml
<!-- Images should describe their purpose -->
<img src="icons/search.tga" alt="Search"/>
<!-- Icons with meaning need labels -->
<button aria-label="Close">
<img src="icons/close.tga"/>
</button>
```
### Ensure Color Contrast
Text should have at least 4.5:1 contrast ratio:
```css
/* Good contrast */
.light-text {
color: #ffffff;
background-color: #1a1a2e; /* Contrast: 12.6:1 */
}
/* Bad contrast */
.low-contrast {
color: #888888;
background-color: #666666; /* Contrast: 1.3:1 */
}
```
### Don't Rely on Color Alone
```css
/* Bad: Only color indicates error */
.error {
color: red;
}
/* Good: Icon + color + text */
.error {
color: #ff4444;
}
.error::before {
content: "⚠ ";
}
```
## Testing
### Test Error States
Don't just test the happy path:
```lua
-- Test these scenarios:
-- 1. Empty data
-- 2. Network failure
-- 3. Invalid input
-- 4. Timeouts
-- 5. Missing permissions
```
### Test Navigation Flows
Ensure users can:
- Navigate forward and back
- Return to the home screen
- Handle the back button at any screen
### Test Edge Cases
- Very long text/names
- Empty lists
- Maximum values
- Rapid repeated actions
- Interrupted operations
### Use Debug Logging
```lua
local DEBUG = true
function debugLog(...)
if DEBUG then
print("[DEBUG]", ...)
end
end
-- In production build, set DEBUG = false
```
## Deployment
### Use Meaningful Version Numbers
Follow semantic versioning:
- **MAJOR**: Breaking changes
- **MINOR**: New features, backward compatible
- **PATCH**: Bug fixes
```json
{
"version": "2.1.3",
"version_code": 15
}
```
### Write Good Release Notes
```
Version 2.1.0
New Features:
- Added dark mode support
- New export to PDF feature
Improvements:
- Faster loading times
- Better error messages
Bug Fixes:
- Fixed crash when opening empty files
- Fixed date format on some devices
```
### Test Before Submitting
1. Run on the Designer
2. Test all features manually
3. Check on a real device if possible
4. Verify all assets load correctly
5. Test offline behavior
## Summary Checklist
Before submitting your app:
- [ ] All features work as expected
- [ ] Error states are handled gracefully
- [ ] Loading states shown during async operations
- [ ] Touch targets are at least 48dp
- [ ] Text is readable (contrast ratio ≥ 4.5:1)
- [ ] No console errors in normal usage
- [ ] Timers and intervals cleaned up properly
- [ ] User data persists correctly
- [ ] App works after fresh install
- [ ] Version number and code are updated
- [ ] Release notes are meaningful
## See Also
- [UI Design Guide](ui-design.md) - Design patterns
- [Lua Scripting Guide](lua-scripting.md) - Code patterns
- [Troubleshooting](../troubleshooting.md) - Common issues

View File

@@ -0,0 +1,506 @@
# Lua Scripting Guide
Mosis apps use Lua for scripting and interactivity. Each app runs in an isolated sandbox with access to Mosis-specific APIs.
## Getting Started
Embed Lua directly in your RML files:
```xml
<body>
<button onclick="sayHello()">Click Me</button>
<script>
function sayHello()
print("Hello from Lua!")
end
</script>
</body>
```
Or use external files:
```xml
<head>
<script src="scripts/app.lua"/>
</head>
```
## Lua Basics
If you're new to Lua, here's a quick primer:
### Variables
```lua
-- Local variables (preferred)
local name = "Mosis"
local count = 42
local enabled = true
local items = {"apple", "banana", "cherry"}
-- Global variables (avoid when possible)
globalVar = "accessible everywhere"
```
### Functions
```lua
-- Basic function
function greet(name)
return "Hello, " .. name .. "!"
end
-- Function with multiple returns
function getPosition()
return 100, 200
end
local x, y = getPosition()
-- Anonymous functions
local double = function(n) return n * 2 end
```
### Control Flow
```lua
-- If statements
if score > 100 then
print("High score!")
elseif score > 50 then
print("Good job!")
else
print("Keep trying!")
end
-- Loops
for i = 1, 10 do
print(i)
end
for index, value in ipairs(items) do
print(index, value)
end
while condition do
-- loop body
end
```
### Tables
```lua
-- Array-like table
local colors = {"red", "green", "blue"}
print(colors[1]) -- "red" (Lua is 1-indexed)
-- Dictionary-like table
local user = {
name = "Alice",
age = 25,
premium = true
}
print(user.name)
print(user["age"])
-- Mixed table
local app = {
name = "MyApp",
version = "1.0",
features = {"dark mode", "notifications"}
}
```
## DOM Manipulation
Access and modify UI elements using the `document` object:
### Getting Elements
```lua
-- By ID
local button = document:GetElementById("my-button")
-- By tag name
local paragraphs = document:GetElementsByTagName("p")
-- By class name
local cards = document:GetElementsByClassName("card")
```
### Modifying Content
```lua
local element = document:GetElementById("message")
-- Set inner content (HTML-like)
element.inner_rml = "<strong>Hello!</strong>"
-- Get inner content
local content = element.inner_rml
-- Set text only (safer, no HTML parsing)
element:SetInnerRML("Plain text here")
```
### Modifying Attributes
```lua
local input = document:GetElementById("username")
-- Get attribute
local value = input:GetAttribute("value")
-- Set attribute
input:SetAttribute("placeholder", "Enter username")
-- Remove attribute
input:RemoveAttribute("disabled")
```
### Modifying Styles
```lua
local box = document:GetElementById("box")
-- Set individual properties
box.style.width = "200dp"
box.style.backgroundColor = "#00d4ff"
box.style.display = "none" -- hide element
-- Read properties
local width = box.style.width
```
### Classes
```lua
local element = document:GetElementById("panel")
-- Add class
element:SetClass("active", true)
-- Remove class
element:SetClass("active", false)
-- Check class
if element:IsClassSet("active") then
print("Panel is active")
end
```
## Event Handling
### Inline Events
```xml
<button onclick="handleClick()">Click</button>
<input onchange="handleChange(event)"/>
<div onmouseover="handleHover()"/>
```
### Event Listeners
```lua
local button = document:GetElementById("my-button")
-- Add listener
button:AddEventListener("click", function(event)
print("Button clicked!")
end)
-- Remove listener (need reference)
local handler = function(event)
print("Clicked")
end
button:AddEventListener("click", handler)
button:RemoveEventListener("click", handler)
```
### Event Object
```lua
function handleEvent(event)
-- Event type
print(event.type) -- "click", "change", etc.
-- Target element
local target = event:GetCurrentElement()
-- Mouse position (for mouse events)
local x = event.parameters.mouse_x
local y = event.parameters.mouse_y
-- Stop propagation
event:StopPropagation()
end
```
### Common Events
| Event | Description |
|-------|-------------|
| `click` | Element clicked |
| `dblclick` | Element double-clicked |
| `mousedown` | Mouse button pressed |
| `mouseup` | Mouse button released |
| `mouseover` | Mouse enters element |
| `mouseout` | Mouse leaves element |
| `focus` | Element gains focus |
| `blur` | Element loses focus |
| `change` | Input value changed |
| `submit` | Form submitted |
| `keydown` | Key pressed |
| `keyup` | Key released |
## Timers
### setTimeout
```lua
-- Execute once after delay
local timerId = setTimeout(function()
print("Executed after 1 second")
end, 1000) -- milliseconds
-- Cancel timer
clearTimeout(timerId)
```
### setInterval
```lua
-- Execute repeatedly
local intervalId = setInterval(function()
print("Tick")
end, 1000)
-- Cancel interval
clearInterval(intervalId)
```
## Storage
Persist data between app sessions:
```lua
-- Save data
storage.set("username", "Alice")
storage.set("settings", {
darkMode = true,
notifications = false
})
-- Load data
local username = storage.get("username")
local settings = storage.get("settings")
-- Delete data
storage.remove("username")
-- Clear all data
storage.clear()
```
## Navigation
Navigate between screens in your app:
```lua
-- Navigate to screen
navigateTo("settings") -- loads assets/settings.rml
-- Go back
goBack()
-- Go to home screen
goHome()
-- Replace current screen (no back)
replaceTo("login")
```
### Navigation Events
```lua
-- Listen for navigation
onNavigate(function(screenName)
print("Navigated to: " .. screenName)
end)
-- Listen for back
onBack(function()
print("Going back")
end)
```
## HTTP Requests
Make network requests (requires `network` permission):
```lua
-- GET request
http.get("https://api.example.com/data", function(response)
if response.ok then
local data = json.decode(response.body)
print(data.message)
else
print("Error: " .. response.status)
end
end)
-- POST request
http.post("https://api.example.com/submit", {
headers = {
["Content-Type"] = "application/json"
},
body = json.encode({
name = "Alice",
action = "subscribe"
})
}, function(response)
print("Status: " .. response.status)
end)
```
## JSON
```lua
-- Parse JSON string
local data = json.decode('{"name": "Alice", "age": 25}')
print(data.name)
-- Convert to JSON string
local str = json.encode({
items = {"a", "b", "c"},
count = 3
})
```
## Date and Time
```lua
-- Current timestamp
local now = os.time()
-- Format date
local formatted = os.date("%Y-%m-%d %H:%M:%S", now)
-- Parse date components
local t = os.date("*t", now)
print(t.year, t.month, t.day, t.hour, t.min, t.sec)
```
## Utilities
### String Functions
```lua
-- Concatenation
local greeting = "Hello, " .. name .. "!"
-- String functions
string.upper("hello") -- "HELLO"
string.lower("HELLO") -- "hello"
string.sub("hello", 1, 3) -- "hel"
string.find("hello", "ll") -- 3
string.gsub("hello", "l", "L") -- "heLLo"
string.format("Score: %d", 100) -- "Score: 100"
```
### Math Functions
```lua
math.floor(3.7) -- 3
math.ceil(3.2) -- 4
math.round(3.5) -- 4
math.abs(-5) -- 5
math.min(1, 2, 3) -- 1
math.max(1, 2, 3) -- 3
math.random() -- 0-1
math.random(1, 6) -- 1-6
```
### Table Functions
```lua
-- Insert
table.insert(items, "new item")
table.insert(items, 1, "at beginning")
-- Remove
table.remove(items) -- remove last
table.remove(items, 1) -- remove first
-- Sort
table.sort(items)
table.sort(items, function(a, b) return a > b end) -- descending
-- Length
local count = #items
```
## Sandbox Restrictions
For security, these are **NOT** available:
- `os.execute`, `io.popen` - No shell commands
- `loadfile`, `dofile` - No arbitrary file loading
- `require` - No external modules (use `import` for app modules)
- `debug` library - No debugging hooks
- `rawget`, `rawset` - No metatable bypass
## Best Practices
1. **Use local variables** - Faster and prevents pollution
2. **Handle errors** - Use `pcall` for operations that might fail
3. **Clean up timers** - Clear intervals when navigating away
4. **Minimize DOM queries** - Cache element references
5. **Batch updates** - Group style changes together
### Error Handling
```lua
local success, result = pcall(function()
-- Code that might fail
local data = json.decode(invalidJson)
return data
end)
if success then
print("Parsed:", result)
else
print("Error:", result)
end
```
### Module Pattern
```lua
-- utils.lua
local Utils = {}
function Utils.formatCurrency(amount)
return string.format("$%.2f", amount)
end
function Utils.capitalize(str)
return str:sub(1,1):upper() .. str:sub(2)
end
return Utils
```
```lua
-- main.lua
local Utils = import("utils")
print(Utils.formatCurrency(19.99))
```
## Next Steps
- [Permissions Guide](permissions.md) - Request device capabilities
- [API Reference](../api/lua-api.md) - Complete API documentation
- [Debugging Guide](debugging.md) - Debug your Lua code

View File

@@ -0,0 +1,396 @@
# Permissions Guide
Mosis apps run in a secure sandbox with limited access to device features. To access sensitive capabilities, apps must declare permissions in their manifest.
## Why Permissions?
Permissions protect user privacy and security by:
1. **Informing users** what an app can access before installation
2. **Limiting damage** if an app misbehaves
3. **Maintaining trust** in the Mosis ecosystem
## Declaring Permissions
Add permissions to your `manifest.json`:
```json
{
"id": "com.example.myapp",
"name": "My App",
"permissions": [
"storage",
"network"
]
}
```
Only request permissions your app actually needs. Users are more likely to trust apps with fewer permissions.
## Available Permissions
### storage
**Description:** Persist data locally between app sessions.
**Use cases:**
- Save user preferences
- Cache data for offline use
- Store app state
**API access:**
```lua
storage.set("key", value)
storage.get("key")
storage.remove("key")
storage.clear()
```
**Note:** All apps have access to in-memory storage during a session. The `storage` permission enables persistence across sessions.
---
### network
**Description:** Make HTTP/HTTPS requests to external servers.
**Use cases:**
- Fetch data from APIs
- Submit form data
- Load remote content
**API access:**
```lua
http.get(url, callback)
http.post(url, options, callback)
http.request(options, callback)
```
**Restrictions:**
- HTTPS only (HTTP blocked for security)
- Cannot access localhost or internal IPs
- Subject to CORS policies
---
### clipboard
**Description:** Read from and write to the system clipboard.
**Use cases:**
- Copy text or data
- Paste user content
- Share functionality
**API access:**
```lua
clipboard.write(text)
clipboard.read(callback)
```
---
### notifications
**Description:** Display system notifications to the user.
**Use cases:**
- Reminders
- Alerts
- Background updates
**API access:**
```lua
notifications.show({
title = "Reminder",
body = "Your timer is done!",
icon = "icons/alarm.png"
})
```
**Restrictions:**
- Notifications may be rate-limited
- Users can disable notifications per-app
---
### camera
**Description:** Capture photos using the device camera.
**Use cases:**
- Photo capture
- QR code scanning
- Augmented reality
**API access:**
```lua
camera.capture({
quality = "high",
facing = "back"
}, function(result)
if result.success then
local imageData = result.data
end
end)
```
**Restrictions:**
- User prompt before first access
- Cannot record video (photo only)
---
### microphone
**Description:** Record audio from the device microphone.
**Use cases:**
- Voice notes
- Audio messages
- Voice commands
**API access:**
```lua
microphone.start()
microphone.stop(function(result)
local audioData = result.data
end)
```
**Restrictions:**
- User prompt before first access
- Maximum recording duration enforced
---
### location
**Description:** Access device location information.
**Use cases:**
- Weather apps
- Maps
- Location-based features
**API access:**
```lua
location.get(function(result)
if result.success then
print(result.latitude, result.longitude)
end
end)
location.watch(function(result)
-- Called on location changes
end)
```
**Restrictions:**
- User prompt before first access
- Approximate location only (no precise GPS)
- Battery impact warning
---
### contacts
**Description:** Read device contacts.
**Use cases:**
- Contact picker
- Address book integration
- Sharing with friends
**API access:**
```lua
contacts.pick(function(result)
if result.success then
print(result.name, result.phone)
end
end)
contacts.getAll(function(result)
for i, contact in ipairs(result.contacts) do
print(contact.name)
end
end)
```
**Restrictions:**
- Read-only access
- User prompt before first access
## Permission Levels
| Level | Description | Example |
|-------|-------------|---------|
| **Normal** | Low risk, minimal review | storage |
| **Sensitive** | Requires user prompt | camera, microphone, location |
| **Dangerous** | Extensive review required | contacts |
## Runtime Behavior
### First-Time Prompts
Some permissions trigger a user prompt on first use:
```lua
-- First call triggers prompt
camera.capture(options, function(result)
if result.denied then
-- User denied permission
showPermissionExplanation()
elseif result.success then
-- Permission granted
handlePhoto(result.data)
end
end)
```
### Checking Permission Status
```lua
-- Check if permission is granted
if permissions.check("camera") then
-- Already have permission
showCameraButton()
else
-- Need to request
showRequestButton()
end
```
### Requesting at Runtime
```lua
permissions.request("camera", function(granted)
if granted then
startCamera()
else
showAlternative()
end
end)
```
## Best Practices
### 1. Minimize Permissions
Only request what you need. An app with fewer permissions:
- Builds more user trust
- Passes review faster
- Has smaller attack surface
### 2. Request at the Right Time
Don't request all permissions at startup. Request when the user takes an action that needs it:
```lua
-- Bad: Request on app start
function onAppStart()
permissions.request("camera") -- Why?
end
-- Good: Request when needed
function onTakePhotoClicked()
permissions.request("camera", function(granted)
if granted then
camera.capture(options, handlePhoto)
end
end)
end
```
### 3. Explain Why
Tell users why you need a permission before requesting:
```xml
<div id="permission-explanation" style="display: none;">
<p>This app needs camera access to scan QR codes.</p>
<button onclick="requestCamera()">Enable Camera</button>
</div>
```
### 4. Handle Denial Gracefully
Apps should work (with reduced functionality) even if permissions are denied:
```lua
function capturePhoto()
if not permissions.check("camera") then
-- Offer alternative
showManualEntryOption()
return
end
-- Proceed with camera
end
```
### 5. Don't Ask Again Immediately
If a user denies a permission, don't immediately ask again:
```lua
local lastDenied = storage.get("camera_denied_time")
if lastDenied and os.time() - lastDenied < 86400 then
-- Wait at least 24 hours before asking again
return
end
```
## Review Impact
Permission requests affect app review:
| Permission | Review Impact |
|------------|---------------|
| storage, network | Automatic approval |
| clipboard | Quick review |
| notifications | Standard review |
| camera, microphone | Extended review |
| location | Extended review |
| contacts | Manual review required |
Apps requesting sensitive permissions must:
1. Justify the need in submission notes
2. Use the permission appropriately
3. Respect user privacy
## Troubleshooting
### "Permission not declared"
```
Error: Cannot use camera without 'camera' permission
```
Add the permission to your manifest:
```json
"permissions": ["camera"]
```
### "Permission denied by user"
Handle this gracefully in your code:
```lua
if result.denied then
showAlternativeUI()
end
```
### "Permission blocked"
The user permanently blocked the permission. Direct them to settings:
```lua
if result.blocked then
showMessage("Please enable camera in system settings")
end
```
## See Also
- [Manifest Reference](../api/manifest.md) - Full manifest documentation
- [Security Guide](security.md) - App security best practices
- [Publishing Guide](publishing.md) - App review process

View File

@@ -0,0 +1,395 @@
# UI Design Guide
Mosis uses RML (RmlUi Markup Language) and RCSS (RmlUi CSS) for building user interfaces. If you know HTML and CSS, you'll feel right at home.
## RML Basics
RML is similar to HTML but with some differences optimized for UI rendering.
### Document Structure
```xml
<rml>
<head>
<title>App Title</title>
<link type="text/rcss" href="styles.rcss"/>
</head>
<body>
<!-- Your UI here -->
</body>
</rml>
```
### Common Elements
| Element | Usage |
|---------|-------|
| `<div>` | Container/layout |
| `<p>` | Paragraph text |
| `<span>` | Inline text |
| `<h1>` - `<h6>` | Headings |
| `<img>` | Images |
| `<button>` | Clickable buttons |
| `<input>` | Text input fields |
| `<select>` | Dropdown menus |
| `<progress>` | Progress bars |
### Layout Example
```xml
<div class="app-bar">
<div class="app-bar-nav" onclick="goBack()">
<img src="../../icons/back.tga"/>
</div>
<span class="app-bar-title">My App</span>
</div>
<div class="content">
<div class="card">
<h2>Welcome</h2>
<p>This is a card component.</p>
</div>
</div>
<div class="dock">
<button class="dock-item" onclick="navigateTo('home')">
<img src="icons/home.tga"/>
</button>
</div>
```
## RCSS Styling
RCSS is CSS with some limitations and extensions.
### Supported Properties
**Layout:**
- `display` (block, inline, inline-block, flex, none)
- `position` (static, relative, absolute, fixed)
- `width`, `height`, `min-width`, `max-width`, `min-height`, `max-height`
- `margin`, `padding` (including directional variants)
- `flex`, `flex-direction`, `flex-wrap`, `justify-content`, `align-items`
- `overflow` (visible, hidden, scroll, auto)
**Visual:**
- `background-color`, `background` (with decorators)
- `color`
- `border`, `border-radius`
- `opacity`
- `box-shadow` (via decorators)
**Typography:**
- `font-family`
- `font-size`
- `font-weight` (normal, bold)
- `font-style` (normal, italic)
- `text-align` (left, center, right)
- `line-height`
- `text-decoration`
### Units
| Unit | Description |
|------|-------------|
| `dp` | Density-independent pixels (recommended) |
| `px` | Pixels |
| `%` | Percentage of parent |
| `em` | Relative to font size |
Always use `dp` for consistent sizing across devices:
```css
.button {
padding: 12dp 24dp;
font-size: 16dp;
border-radius: 8dp;
}
```
### Colors
```css
/* Hex colors */
color: #ffffff;
color: #fff;
color: #00d4ff80; /* with alpha */
/* RGB/RGBA */
color: rgb(255, 255, 255);
color: rgba(0, 212, 255, 0.5);
```
### Pseudo-classes
```css
button {
background-color: #00d4ff;
}
button:hover {
background-color: #00b8e6;
}
button:active {
background-color: #0099cc;
}
button:focus {
border: 2dp solid #ffffff;
}
button:disabled {
opacity: 0.5;
}
```
## Flexbox Layout
RCSS supports flexbox for modern layouts:
```css
.row {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.column {
display: flex;
flex-direction: column;
gap: 10dp;
}
.grow {
flex: 1;
}
```
```xml
<div class="row">
<span>Left</span>
<span class="grow"></span>
<span>Right</span>
</div>
```
## Images
Images should be in TGA format for best performance:
```xml
<img src="icons/star.tga"/>
```
Supported formats:
- TGA (recommended)
- PNG
- JPEG
### Image Sizing
```css
img {
width: 32dp;
height: 32dp;
}
/* Aspect ratio maintained */
img.icon {
width: 24dp;
height: auto;
}
```
## Input Elements
### Text Input
```xml
<input type="text" id="username" placeholder="Enter username"/>
```
```css
input {
width: 100%;
padding: 12dp;
background-color: #2a2a4e;
border: 1dp solid #3a3a5e;
border-radius: 8dp;
color: #ffffff;
font-size: 16dp;
}
input:focus {
border-color: #00d4ff;
}
```
### Select/Dropdown
```xml
<select id="country">
<option value="us">United States</option>
<option value="uk">United Kingdom</option>
<option value="ca">Canada</option>
</select>
```
### Progress Bar
```xml
<progress id="loading" value="0.5" max="1"/>
```
```css
progress {
width: 100%;
height: 8dp;
background-color: #2a2a4e;
border-radius: 4dp;
}
progress fill {
background-color: #00d4ff;
border-radius: 4dp;
}
```
## Scrolling
```xml
<div class="scroll-container">
<div class="scroll-content">
<!-- Long content here -->
</div>
</div>
```
```css
.scroll-container {
height: 300dp;
overflow: auto;
}
```
## Decorators
RCSS uses decorators for advanced visual effects:
```css
/* Gradient background */
.gradient {
decorator: horizontal-gradient(#1a1a2e #2a2a4e);
}
/* Image background */
.card {
decorator: image(background.tga);
}
/* Border image */
.fancy-border {
decorator: ninepatch(border.tga, 10dp, 10dp, 10dp, 10dp);
}
```
## Animations
RCSS supports keyframe animations:
```css
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.fade-in {
animation: fade-in 0.3s ease-out;
}
```
### Transitions
```css
.button {
transition: background-color 0.2s, transform 0.1s;
}
.button:hover {
background-color: #00b8e6;
}
.button:active {
transform: scale(0.95);
}
```
## Responsive Design
Design for the Mosis phone screen (1080x1920 logical pixels):
```css
/* Base styles for portrait */
.content {
padding: 16dp;
}
/* Adjust for available space */
.app-bar {
height: 56dp;
padding: 0 16dp;
}
.dock {
height: 64dp;
padding: 8dp;
}
```
## Design Tokens
Use CSS variables for consistent theming:
```css
:root {
--primary: #00d4ff;
--primary-dark: #00b8e6;
--background: #1a1a2e;
--surface: #2a2a4e;
--text-primary: #ffffff;
--text-secondary: #a0a0a0;
--spacing-sm: 8dp;
--spacing-md: 16dp;
--spacing-lg: 24dp;
--radius-sm: 4dp;
--radius-md: 8dp;
--radius-lg: 16dp;
}
.button {
background-color: var(--primary);
padding: var(--spacing-md);
border-radius: var(--radius-md);
}
```
## Best Practices
1. **Use dp units** - Ensures consistent sizing across devices
2. **Test touch targets** - Minimum 48dp for touchable elements
3. **Maintain contrast** - Ensure text is readable (4.5:1 ratio minimum)
4. **Use semantic structure** - Proper headings, lists, etc.
5. **Optimize images** - Use TGA format, appropriate sizes
6. **Keep it simple** - Mobile-first design, avoid clutter
## Next Steps
- [Lua Scripting Guide](lua-scripting.md) - Add interactivity
- [Components Library](components.md) - Pre-built UI components
- [Theme Reference](theme.md) - Complete theming guide

View File

@@ -0,0 +1,51 @@
# Mosis Developer Documentation
Welcome to the Mosis developer documentation. Mosis is a virtual smartphone OS for VR games and applications, providing a phone-like device that users can interact with inside VR environments.
## Quick Links
- [Getting Started](getting-started.md) - Create your first Mosis app
- [UI Guide](guides/ui-design.md) - Design beautiful interfaces with RML/RCSS
- [Lua Scripting](guides/lua-scripting.md) - Add interactivity with Lua
- [API Reference](api/lua-api.md) - Complete Lua API documentation
- [Manifest Reference](api/manifest.md) - App manifest schema
## What is Mosis?
Mosis provides a virtual phone interface for VR applications. Developers can create apps that run inside this virtual phone, offering users familiar smartphone experiences within VR games.
### Key Features
- **RML/RCSS UI** - HTML/CSS-like markup for building interfaces
- **Lua Scripting** - Lightweight scripting for app logic
- **Sandboxed Execution** - Secure isolation per app
- **Cross-Platform** - Works with Unity, Unreal Engine, and more
### Architecture
```
┌─────────────────────────────────────────┐
│ Your VR Game/App │
│ (Unity, Unreal, native Android) │
└──────────────────┬──────────────────────┘
┌──────────────────▼──────────────────────┐
│ MosisService │
│ ┌─────────────────────────────────┐ │
│ │ Your Mosis App │ │
│ │ ┌─────────┐ ┌─────────────┐ │ │
│ │ │ RML/CSS │ │ Lua Scripts │ │ │
│ │ └─────────┘ └─────────────┘ │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘
```
## Getting Help
- [Troubleshooting](troubleshooting.md) - Common issues and solutions
- [FAQ](faq.md) - Frequently asked questions
- [API Status](https://status.omixlab.com) - Service status page
## Contributing
Mosis is developed by OmixLab LTD. For questions or feedback, contact us through the developer portal.

View File

@@ -0,0 +1,469 @@
# Troubleshooting
Solutions for common issues when developing Mosis apps.
## Build Errors
### "Invalid manifest: missing required field"
Your `manifest.json` is missing a required field.
**Solution:** Check these required fields:
```json
{
"id": "com.example.app",
"name": "App Name",
"version": "1.0.0",
"version_code": 1,
"entry": "assets/main.rml",
"author": {
"name": "Your Name",
"email": "you@example.com"
},
"icons": {
"128": "icon.png"
}
}
```
### "Invalid package ID format"
Package IDs must follow reverse domain notation.
**Valid:**
- `com.example.myapp`
- `com.yourname.calculator`
- `io.github.user.app`
**Invalid:**
- `myapp` (needs domain prefix)
- `Com.Example.App` (must be lowercase)
- `com..app` (no double dots)
- `com.app.` (can't end with dot)
### "Entry point not found"
The `entry` file specified in manifest doesn't exist.
**Solution:** Verify the path:
```json
"entry": "assets/main.rml"
```
Check that `assets/main.rml` exists relative to your manifest file.
### "Icon not found"
An icon file specified in manifest doesn't exist.
**Solution:**
1. Check file paths are correct
2. Ensure files exist
3. Use forward slashes in paths
```json
"icons": {
"128": "icons/icon-128.png"
}
```
### "Package too large"
Package exceeds the 10MB limit.
**Solutions:**
- Compress images (use TGA or optimized PNG)
- Remove unused assets
- Move large files to external CDN
- Check for accidentally included files
## Runtime Errors
### "attempt to index nil value"
You're accessing a property on a nil variable.
**Common causes:**
1. **Element not found:**
```lua
-- Bad
local elem = document:GetElementById("typo")
elem.inner_rml = "Hello" -- Error: elem is nil
-- Good
local elem = document:GetElementById("correct-id")
if elem then
elem.inner_rml = "Hello"
end
```
2. **Table key doesn't exist:**
```lua
-- Bad
local data = json.decode(response.body)
print(data.user.name) -- Error if user is nil
-- Good
if data and data.user then
print(data.user.name)
end
```
### "attempt to call nil value"
You're calling a function that doesn't exist.
**Common causes:**
1. **Typo in function name:**
```lua
-- Bad: navigateto (lowercase t)
navigateto("settings")
-- Good
navigateTo("settings")
```
2. **Missing permission:**
```lua
-- Error if 'network' permission not declared
http.get(url, callback)
```
### "Permission denied"
You're using an API without the required permission.
**Solution:** Add permission to manifest:
```json
"permissions": ["storage", "network"]
```
### "Network request failed"
HTTP request couldn't complete.
**Common causes:**
1. **No network permission:**
```json
"permissions": ["network"]
```
2. **Invalid URL:**
```lua
-- Bad: missing protocol
http.get("api.example.com/data", callback)
-- Good
http.get("https://api.example.com/data", callback)
```
3. **HTTP not allowed (HTTPS only):**
```lua
-- Bad
http.get("http://example.com/data", callback)
-- Good
http.get("https://example.com/data", callback)
```
4. **CORS error:** The server doesn't allow cross-origin requests. Contact the API provider or use a CORS proxy.
### "Storage quota exceeded"
You've exceeded the 5MB storage limit.
**Solution:**
- Clear unnecessary data: `storage.clear()`
- Use selective removal: `storage.remove("large-key")`
- Store only essential data
- Consider using network storage for large data
## UI Issues
### Element not displaying
**Check:**
1. **Display not set to none:**
```css
/* Element might be hidden */
.element {
display: none; /* Remove this */
}
```
2. **Size is zero:**
```css
.element {
width: 0; /* Add dimensions */
height: 0;
}
```
3. **Element is off-screen:**
```css
.element {
position: absolute;
left: -1000dp; /* Move to visible area */
}
```
4. **Z-index issues:**
```css
.element {
z-index: 1; /* Bring to front */
}
```
### Click events not working
**Check:**
1. **Function exists:**
```xml
<button onclick="handleClick()">Click</button>
```
```lua
-- Make sure function is defined
function handleClick()
print("Clicked!")
end
```
2. **Element is overlapped:**
Another element might be blocking clicks. Check z-index and position.
3. **Element has pointer-events: none:**
```css
.element {
/* Remove this */
pointer-events: none;
}
```
### Styles not applying
**Check:**
1. **Stylesheet is linked:**
```xml
<head>
<link type="text/rcss" href="styles.rcss"/>
</head>
```
2. **Selector is correct:**
```css
/* Class selector needs dot */
.my-class { }
/* ID selector needs hash */
#my-id { }
/* Tag selector has no prefix */
button { }
```
3. **Specificity issues:**
More specific selectors override less specific ones:
```css
/* Less specific */
button { color: blue; }
/* More specific - wins */
.btn.primary { color: red; }
```
4. **Units are correct:**
```css
/* Use dp units */
padding: 12dp;
/* Not px on mobile */
padding: 12px; /* May not work correctly */
```
### Layout breaks on different screens
**Solutions:**
1. **Use dp units instead of px:**
```css
padding: 16dp; /* Scales properly */
```
2. **Use flexbox:**
```css
.container {
display: flex;
flex-direction: column;
}
```
3. **Use percentage widths:**
```css
.card {
width: 90%;
max-width: 400dp;
}
```
### Text is cut off
**Solutions:**
1. **Allow wrapping:**
```css
.text {
word-break: break-word;
}
```
2. **Add overflow scrolling:**
```css
.container {
overflow: auto;
}
```
3. **Use ellipsis (if supported):**
```css
.text {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
```
## Designer Issues
### Hot reload not working
**Solutions:**
1. **Save the file** - Changes only reload on save
2. **Check file is in watch path**
3. **Restart designer** - Sometimes needed after many changes
4. **Check for syntax errors** - Invalid files may not reload
### Designer crashes on startup
**Solutions:**
1. **Check file paths:**
```bash
# Make sure path exists
mosis-designer.exe ../assets/main.rml
```
2. **Try a simple file first:**
```xml
<rml>
<head><title>Test</title></head>
<body><p>Hello</p></body>
</rml>
```
3. **Check for missing assets:**
Images or fonts that don't exist can cause crashes.
4. **Update graphics drivers:**
The designer uses OpenGL which requires up-to-date drivers.
### Rendering looks different on device
**Common causes:**
1. **Font differences** - Ensure fonts are bundled
2. **DPI scaling** - Use dp units consistently
3. **Color profiles** - Use standard sRGB colors
## Submission Issues
### "Version code must be higher"
Each new version needs a higher version_code.
**Solution:**
```json
{
"version": "1.0.1",
"version_code": 2 // Increment from previous
}
```
### "Signature verification failed"
Your package signature is invalid.
**Solutions:**
1. **Rebuild the package:**
```bash
mosis build
```
2. **Check signing key is registered:**
```bash
mosis keys list
```
3. **Re-register your key if needed:**
```bash
mosis keys register ~/.mosis/keys/production.pub
```
### "Review rejected"
Check the rejection reason in your dashboard. Common issues:
| Reason | Solution |
|--------|----------|
| Inappropriate content | Remove violating content |
| Misleading description | Update description to match functionality |
| Crashes on launch | Fix startup errors |
| Missing privacy policy | Add privacy policy for data-collecting apps |
| Impersonation | Don't copy other apps |
## Getting More Help
### Check Logs
**Designer logs:**
```bash
mosis-designer.exe app.rml --log debug.log
```
**Lua errors:**
```lua
-- Add error handling
local success, err = pcall(function()
-- Your code
end)
if not success then
print("Error:", err)
end
```
### Search Issues
Check if others have encountered the same issue:
- Developer forum
- GitHub issues
- Stack Overflow (tag: mosis)
### Contact Support
If you're still stuck:
1. Gather error messages and logs
2. Create minimal reproduction case
3. Submit through developer portal support
## See Also
- [FAQ](faq.md) - Frequently asked questions
- [Lua API Reference](api/lua-api.md) - API documentation
- [UI Design Guide](guides/ui-design.md) - Styling reference

View File

@@ -2,15 +2,22 @@ package web
import (
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/omixlab/mosis-portal/internal/database"
"omixlab.com/mosis-portal/internal/database"
"omixlab.com/mosis-portal/internal/review"
"omixlab.com/mosis-portal/internal/storage"
"omixlab.com/mosis-portal/internal/telemetry"
)
// Handler handles web page requests
type Handler struct {
db *database.DB
templates *Templates
store *storage.Storage
review *review.Service
telemetry *telemetry.Service
}
// NewHandler creates a new web handler
@@ -22,9 +29,20 @@ func NewHandler(db *database.DB) (*Handler, error) {
return &Handler{
db: db,
templates: templates,
review: review.New(db),
}, nil
}
// SetStorage sets the storage instance for the handler
func (h *Handler) SetStorage(store *storage.Storage) {
h.store = store
}
// SetTelemetry sets the telemetry service for the handler
func (h *Handler) SetTelemetry(ts *telemetry.Service) {
h.telemetry = ts
}
// PageData is the base data structure for all pages
type PageData struct {
Title string
@@ -192,3 +210,374 @@ func getDeveloperFromContext(r *http.Request) *database.Developer {
developer, _ := r.Context().Value("developer").(*database.Developer)
return developer
}
// AdminReviewQueue renders the admin review queue page
func (h *Handler) AdminReviewQueue(w http.ResponseWriter, r *http.Request) {
developer := getDeveloperFromContext(r)
if developer == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// TODO: Add admin role check here
pending, approved, rejected, _ := h.db.GetReviewStats(r.Context())
data := struct {
PageData
Stats struct {
Pending int
Approved int
Rejected int
}
}{
PageData: PageData{
Title: "Review Queue",
ActiveNav: "admin",
Developer: developer,
},
}
data.Stats.Pending = pending
data.Stats.Approved = approved
data.Stats.Rejected = rejected
h.render(w, "admin_review_queue", data)
}
// AdminReviewDetail renders the review detail page
func (h *Handler) AdminReviewDetail(w http.ResponseWriter, r *http.Request) {
developer := getDeveloperFromContext(r)
if developer == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
versionID := chi.URLParam(r, "versionID")
vwa, err := h.db.GetVersionWithApp(r.Context(), versionID)
if err != nil {
http.Error(w, "Version not found", http.StatusNotFound)
return
}
// Run validation if package exists
var validationResult *review.FullValidationResult
if h.store != nil && vwa.Version.PackageURL != "" {
packagePath := h.store.GetPackagePath(vwa.Version.PackageURL)
result, err := h.review.ValidatePackage(packagePath)
if err == nil {
validationResult = result
}
}
data := struct {
PageData
App *database.App
Version *database.AppVersion
DeveloperName string
DeveloperEmail string
Validation *review.FullValidationResult
}{
PageData: PageData{
Title: "Review: " + vwa.App.Name,
ActiveNav: "admin",
Developer: developer,
},
App: vwa.App,
Version: vwa.Version,
DeveloperName: vwa.DeveloperName,
DeveloperEmail: vwa.DeveloperEmail,
Validation: validationResult,
}
h.render(w, "admin_review_detail", data)
}
// AdminReviewQueuePartial renders the review queue list partial for htmx
func (h *Handler) AdminReviewQueuePartial(w http.ResponseWriter, r *http.Request) {
developer := getDeveloperFromContext(r)
if developer == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
limit := 20
offset := (page - 1) * limit
items, total, err := h.db.GetVersionsInReview(r.Context(), limit, offset)
if err != nil {
http.Error(w, "Failed to load queue", http.StatusInternalServerError)
return
}
data := struct {
Items []database.VersionWithApp
Pagination struct {
Page int
Limit int
Total int
TotalPages int
}
}{
Items: items,
}
data.Pagination.Page = page
data.Pagination.Limit = limit
data.Pagination.Total = total
data.Pagination.TotalPages = (total + limit - 1) / limit
h.renderPartial(w, "review_queue_list", data)
}
// AdminApprove handles approval of a version from the admin UI
func (h *Handler) AdminApprove(w http.ResponseWriter, r *http.Request) {
developer := getDeveloperFromContext(r)
if developer == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
versionID := chi.URLParam(r, "versionID")
notes := r.FormValue("notes")
// Verify version exists and is in review
version, err := h.db.GetVersion(r.Context(), versionID)
if err != nil {
http.Error(w, "Version not found", http.StatusNotFound)
return
}
if version.Status != "in_review" {
http.Error(w, "Version is not in review", http.StatusBadRequest)
return
}
// Approve the version
if err := h.review.ApproveVersion(r.Context(), versionID, notes); err != nil {
http.Error(w, "Failed to approve version: "+err.Error(), http.StatusInternalServerError)
return
}
// Redirect back to queue via htmx
w.Header().Set("HX-Redirect", "/admin/review-queue")
w.WriteHeader(http.StatusOK)
}
// AdminReject handles rejection of a version from the admin UI
func (h *Handler) AdminReject(w http.ResponseWriter, r *http.Request) {
developer := getDeveloperFromContext(r)
if developer == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
versionID := chi.URLParam(r, "versionID")
reason := r.FormValue("reason")
message := r.FormValue("message")
if reason == "" {
http.Error(w, "Rejection reason is required", http.StatusBadRequest)
return
}
// Verify version exists and is in review
version, err := h.db.GetVersion(r.Context(), versionID)
if err != nil {
http.Error(w, "Version not found", http.StatusNotFound)
return
}
if version.Status != "in_review" {
http.Error(w, "Version is not in review", http.StatusBadRequest)
return
}
// Reject the version
feedback := &review.RejectionFeedback{
Reason: reason,
Message: message,
CanResubmit: true,
}
if err := h.review.RejectVersion(r.Context(), versionID, feedback); err != nil {
http.Error(w, "Failed to reject version: "+err.Error(), http.StatusInternalServerError)
return
}
// Redirect back to queue via htmx
w.Header().Set("HX-Redirect", "/admin/review-queue")
w.WriteHeader(http.StatusOK)
}
// AdminValidate runs validation and returns HTML partial with results
func (h *Handler) AdminValidate(w http.ResponseWriter, r *http.Request) {
developer := getDeveloperFromContext(r)
if developer == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
versionID := chi.URLParam(r, "versionID")
// Get version with package info
version, err := h.db.GetVersion(r.Context(), versionID)
if err != nil {
http.Error(w, "Version not found", http.StatusNotFound)
return
}
if version.PackageURL == "" {
http.Error(w, "Version has no uploaded package", http.StatusBadRequest)
return
}
// Run validation
packagePath := h.store.GetPackagePath(version.PackageURL)
result, err := h.review.ValidatePackage(packagePath)
if err != nil {
http.Error(w, "Validation error: "+err.Error(), http.StatusInternalServerError)
return
}
// Render validation results partial
h.renderPartial(w, "validation_results", result)
}
// AppAnalytics renders the app analytics page
func (h *Handler) AppAnalytics(w http.ResponseWriter, r *http.Request) {
developer := getDeveloperFromContext(r)
if developer == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
appID := chi.URLParam(r, "appID")
app, err := h.db.GetApp(r.Context(), appID)
if err != nil {
http.Error(w, "App not found", http.StatusNotFound)
return
}
// Verify ownership
if app.DeveloperID != developer.ID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Get days parameter
days := 30
if d := r.URL.Query().Get("days"); d != "" {
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 && parsed <= 365 {
days = parsed
}
}
// Get analytics data
var overview *telemetry.AnalyticsOverview
var eventStats []telemetry.DailyStats
var crashes []*telemetry.CrashGroup
if h.telemetry != nil {
overview, _ = h.telemetry.GetAnalyticsOverview(r.Context(), appID, days)
eventStats, _ = h.telemetry.GetDailyStats(r.Context(), appID, "", days)
crashes, _, _ = h.telemetry.GetCrashGroups(r.Context(), appID, "open", 5, 0)
}
if overview == nil {
overview = &telemetry.AnalyticsOverview{}
}
data := struct {
PageData
App *database.App
Days int
Overview *telemetry.AnalyticsOverview
EventStats []telemetry.DailyStats
Crashes []*telemetry.CrashGroup
}{
PageData: PageData{
Title: app.Name + " - Analytics",
ActiveNav: "apps",
Developer: developer,
},
App: app,
Days: days,
Overview: overview,
EventStats: eventStats,
Crashes: crashes,
}
h.render(w, "app_analytics", data)
}
// AppCrashes renders the app crashes page
func (h *Handler) AppCrashes(w http.ResponseWriter, r *http.Request) {
developer := getDeveloperFromContext(r)
if developer == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
appID := chi.URLParam(r, "appID")
app, err := h.db.GetApp(r.Context(), appID)
if err != nil {
http.Error(w, "App not found", http.StatusNotFound)
return
}
// Verify ownership
if app.DeveloperID != developer.ID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Get status filter
status := r.URL.Query().Get("status")
if status == "" {
status = "open"
}
// Pagination
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
limit := 20
offset := (page - 1) * limit
// Get crashes
var crashes []*telemetry.CrashGroup
var total int
if h.telemetry != nil {
crashes, total, _ = h.telemetry.GetCrashGroups(r.Context(), appID, status, limit, offset)
}
data := struct {
PageData
App *database.App
Status string
Crashes []*telemetry.CrashGroup
Pagination struct {
Page int
Limit int
Total int
TotalPages int
}
}{
PageData: PageData{
Title: app.Name + " - Crashes",
ActiveNav: "apps",
Developer: developer,
},
App: app,
Status: status,
Crashes: crashes,
}
data.Pagination.Page = page
data.Pagination.Limit = limit
data.Pagination.Total = total
data.Pagination.TotalPages = (total + limit - 1) / limit
h.render(w, "app_crashes", data)
}

View File

@@ -5,7 +5,7 @@ import (
"encoding/gob"
"net/http"
"github.com/omixlab/mosis-portal/internal/database"
"omixlab.com/mosis-portal/internal/database"
)
func init() {

View File

@@ -17,6 +17,28 @@ type Templates struct {
templates map[string]*template.Template
}
// Template helper functions
var templateFuncs = template.FuncMap{
"divFloat": func(a int64, b int) float64 {
return float64(a) / float64(b)
},
"add": func(a, b int) int {
return a + b
},
"sub": func(a, b int) int {
return a - b
},
"mul": func(a, b int) int {
return a * b
},
"min": func(a, b int) int {
if a < b {
return a
}
return b
},
}
// NewTemplates creates and parses all templates
func NewTemplates() (*Templates, error) {
t := &Templates{
@@ -47,7 +69,7 @@ func NewTemplates() (*Templates, error) {
files := append([]string{pageFile}, baseFiles...)
// Read and parse all files
tmpl := template.New(filepath.Base(pageFile))
tmpl := template.New(filepath.Base(pageFile)).Funcs(templateFuncs)
for _, file := range files {
content, err := templateFS.ReadFile(file)
if err != nil {
@@ -70,7 +92,7 @@ func NewTemplates() (*Templates, error) {
if err != nil {
return nil, err
}
tmpl, err := template.New(filepath.Base(partialFile)).Parse(string(content))
tmpl, err := template.New(filepath.Base(partialFile)).Funcs(templateFuncs).Parse(string(content))
if err != nil {
return nil, err
}

View File

@@ -0,0 +1,206 @@
{{define "content"}}
<div class="mb-8">
<a href="/admin/review-queue" class="text-indigo-600 hover:text-indigo-800 text-sm mb-2 inline-flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back to Queue
</a>
<h1 class="text-2xl font-bold text-gray-900">Review: {{.App.Name}}</h1>
<p class="text-gray-600 mt-1">Version {{.Version.VersionName}} ({{.Version.VersionCode}})</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Main Content -->
<div class="lg:col-span-2 space-y-6">
<!-- App Info -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">App Information</h2>
<dl class="grid grid-cols-2 gap-4">
<div>
<dt class="text-sm text-gray-600">Package ID</dt>
<dd class="text-sm font-medium text-gray-900">{{.App.PackageID}}</dd>
</div>
<div>
<dt class="text-sm text-gray-600">Category</dt>
<dd class="text-sm font-medium text-gray-900">{{if .App.Category}}{{.App.Category}}{{else}}Uncategorized{{end}}</dd>
</div>
<div>
<dt class="text-sm text-gray-600">Developer</dt>
<dd class="text-sm font-medium text-gray-900">{{.DeveloperName}}</dd>
</div>
<div>
<dt class="text-sm text-gray-600">Developer Email</dt>
<dd class="text-sm font-medium text-gray-900">{{.DeveloperEmail}}</dd>
</div>
<div class="col-span-2">
<dt class="text-sm text-gray-600">Description</dt>
<dd class="text-sm text-gray-900">{{if .App.Description}}{{.App.Description}}{{else}}<span class="text-gray-400">No description</span>{{end}}</dd>
</div>
</dl>
</div>
<!-- Version Info -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Version Details</h2>
<dl class="grid grid-cols-2 gap-4">
<div>
<dt class="text-sm text-gray-600">Version</dt>
<dd class="text-sm font-medium text-gray-900">{{.Version.VersionName}}</dd>
</div>
<div>
<dt class="text-sm text-gray-600">Version Code</dt>
<dd class="text-sm font-medium text-gray-900">{{.Version.VersionCode}}</dd>
</div>
<div>
<dt class="text-sm text-gray-600">Package Size</dt>
<dd class="text-sm font-medium text-gray-900">{{printf "%.2f" (divFloat .Version.PackageSize 1048576)}} MB</dd>
</div>
<div>
<dt class="text-sm text-gray-600">Submitted</dt>
<dd class="text-sm font-medium text-gray-900">{{.Version.CreatedAt.Format "Jan 2, 2006 3:04 PM"}}</dd>
</div>
<div class="col-span-2">
<dt class="text-sm text-gray-600">Release Notes</dt>
<dd class="text-sm text-gray-900">{{if .Version.ReleaseNotes}}{{.Version.ReleaseNotes}}{{else}}<span class="text-gray-400">No release notes</span>{{end}}</dd>
</div>
{{if .Version.Permissions}}
<div class="col-span-2">
<dt class="text-sm text-gray-600 mb-2">Permissions</dt>
<dd class="flex flex-wrap gap-2">
{{range .Version.Permissions}}
<span class="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded">{{.}}</span>
{{end}}
</dd>
</div>
{{end}}
</dl>
</div>
<!-- Validation Results -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">Validation Results</h2>
<button
hx-get="/admin/review/{{.Version.ID}}/validate"
hx-target="#validation-results"
class="text-sm text-indigo-600 hover:text-indigo-800">
Re-run Validation
</button>
</div>
<div id="validation-results">
{{if .Validation}}
{{if .Validation.Valid}}
<div class="flex items-center text-green-600 mb-4">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Package is valid
</div>
{{else}}
<div class="flex items-center text-red-600 mb-4">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Package validation failed
</div>
{{end}}
{{if .Validation.Flags}}
<div class="space-y-3">
{{range .Validation.Flags}}
<div class="flex items-start p-3 rounded-lg {{if eq .Severity "critical"}}bg-red-50 border border-red-200{{else if eq .Severity "warning"}}bg-yellow-50 border border-yellow-200{{else}}bg-blue-50 border border-blue-200{{end}}">
<span class="px-2 py-0.5 text-xs font-medium rounded {{if eq .Severity "critical"}}bg-red-100 text-red-800{{else if eq .Severity "warning"}}bg-yellow-100 text-yellow-800{{else}}bg-blue-100 text-blue-800{{end}}">
{{.Severity}}
</span>
<div class="ml-3">
<p class="text-sm font-medium text-gray-900">{{.Reason}}</p>
{{if .File}}
<p class="text-xs text-gray-500 mt-1">{{.File}}{{if .Line}}:{{.Line}}{{end}}</p>
{{end}}
</div>
</div>
{{end}}
</div>
{{else}}
<p class="text-sm text-gray-500">No issues found.</p>
{{end}}
{{else}}
<p class="text-sm text-gray-500">Validation not yet run. Click "Re-run Validation" to check the package.</p>
{{end}}
</div>
</div>
</div>
<!-- Sidebar Actions -->
<div class="space-y-6">
<!-- Status -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Status</h2>
<div class="flex items-center">
<span class="w-3 h-3 rounded-full {{if eq .Version.Status "in_review"}}bg-yellow-500{{else if eq .Version.Status "published"}}bg-green-500{{else if eq .Version.Status "rejected"}}bg-red-500{{else}}bg-gray-500{{end}}"></span>
<span class="ml-2 text-sm font-medium text-gray-900 capitalize">{{.Version.Status}}</span>
</div>
{{if .Validation}}
<div class="mt-4 pt-4 border-t border-gray-200">
<p class="text-sm text-gray-600">
{{if .Validation.AutoApprovable}}
<span class="text-green-600">Auto-approvable</span>
{{else}}
<span class="text-yellow-600">Requires manual review</span>
{{end}}
</p>
</div>
{{end}}
</div>
<!-- Actions -->
{{if eq .Version.Status "in_review"}}
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Actions</h2>
<!-- Approve -->
<form hx-post="/admin/review/{{.Version.ID}}/approve" hx-swap="none" class="mb-4">
<label class="block text-sm text-gray-600 mb-2">Approval Notes (optional)</label>
<textarea name="notes" rows="2" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm mb-3" placeholder="Any notes for the developer..."></textarea>
<button type="submit" class="w-full px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 transition-colors">
Approve & Publish
</button>
</form>
<!-- Reject -->
<form hx-post="/admin/review/{{.Version.ID}}/reject" hx-swap="none">
<label class="block text-sm text-gray-600 mb-2">Rejection Reason *</label>
<select name="reason" required class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm mb-3">
<option value="">Select reason...</option>
<option value="security">Security Issue</option>
<option value="quality">Quality Standards</option>
<option value="content">Content Policy Violation</option>
<option value="functionality">Functionality Issues</option>
<option value="metadata">Metadata Issues</option>
<option value="other">Other</option>
</select>
<label class="block text-sm text-gray-600 mb-2">Additional Details</label>
<textarea name="message" rows="2" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm mb-3" placeholder="Specific feedback for the developer..."></textarea>
<button type="submit" class="w-full px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-lg hover:bg-red-700 transition-colors">
Reject
</button>
</form>
</div>
{{end}}
<!-- Download Package -->
{{if .Version.PackageURL}}
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Package</h2>
<a href="/downloads/{{.Version.PackageURL}}" class="inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
Download Package
</a>
</div>
{{end}}
</div>
</div>
{{end}}

View File

@@ -0,0 +1,70 @@
{{define "content"}}
<div class="mb-8">
<h1 class="text-2xl font-bold text-gray-900">Review Queue</h1>
<p class="text-gray-600 mt-1">Apps pending review and approval.</p>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600">Pending Review</p>
<p class="text-3xl font-bold text-yellow-600 mt-1">{{.Stats.Pending}}</p>
</div>
<div class="w-12 h-12 bg-yellow-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600">Approved</p>
<p class="text-3xl font-bold text-green-600 mt-1">{{.Stats.Approved}}</p>
</div>
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600">Rejected</p>
<p class="text-3xl font-bold text-red-600 mt-1">{{.Stats.Rejected}}</p>
</div>
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</div>
</div>
</div>
<!-- Review Queue -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">Pending Reviews</h2>
</div>
<div id="review-queue" hx-get="/admin/partials/review-queue" hx-trigger="load" class="divide-y divide-gray-200">
<div class="p-8 text-center text-gray-500">
<div class="htmx-indicator inline-flex items-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-indigo-600" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Loading review queue...
</div>
</div>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,228 @@
{{define "content"}}
<div class="mb-6">
<a href="/dashboard" class="inline-flex items-center text-sm text-gray-500 hover:text-gray-700">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back to Dashboard
</a>
</div>
<!-- App Header -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
<div class="flex items-start justify-between">
<div class="flex items-center space-x-4">
<div class="w-16 h-16 bg-gray-100 rounded-xl flex items-center justify-center">
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
</div>
<div>
<h1 class="text-2xl font-bold text-gray-900">{{.App.Name}}</h1>
<p class="text-gray-500">{{.App.PackageID}}</p>
</div>
</div>
<div class="flex items-center space-x-2">
<select id="days-select" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-indigo-500 focus:border-indigo-500"
hx-get="/apps/{{.App.ID}}/analytics"
hx-trigger="change"
hx-target="#analytics-content"
hx-select="#analytics-content"
hx-include="this">
<option value="7" {{if eq .Days 7}}selected{{end}}>Last 7 days</option>
<option value="30" {{if eq .Days 30}}selected{{end}}>Last 30 days</option>
<option value="90" {{if eq .Days 90}}selected{{end}}>Last 90 days</option>
</select>
</div>
</div>
</div>
<!-- Tabs -->
<div class="border-b border-gray-200 mb-6">
<nav class="-mb-px flex space-x-8">
<a href="/apps/{{.App.ID}}" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
Overview
</a>
<a href="/apps/{{.App.ID}}/versions" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
Versions
</a>
<a href="/apps/{{.App.ID}}/analytics" class="border-indigo-500 text-indigo-600 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
Analytics
</a>
<a href="/apps/{{.App.ID}}/settings" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
Settings
</a>
</nav>
</div>
<!-- Analytics Content -->
<div id="analytics-content">
<!-- Overview Stats -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center">
<div class="flex-1">
<p class="text-sm font-medium text-gray-500">Daily Active Users</p>
<p class="mt-1 text-3xl font-semibold text-gray-900">{{.Overview.DAU}}</p>
</div>
<div class="{{if ge .Overview.DAUChange 0.0}}text-green-500{{else}}text-red-500{{end}}">
{{if ge .Overview.DAUChange 0.0}}
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/>
</svg>
{{else}}
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6"/>
</svg>
{{end}}
</div>
</div>
<p class="mt-2 text-sm {{if ge .Overview.DAUChange 0.0}}text-green-600{{else}}text-red-600{{end}}">
{{if ge .Overview.DAUChange 0.0}}+{{end}}{{printf "%.1f" .Overview.DAUChange}}% from previous period
</p>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center">
<div class="flex-1">
<p class="text-sm font-medium text-gray-500">Total Sessions</p>
<p class="mt-1 text-3xl font-semibold text-gray-900">{{.Overview.TotalSessions}}</p>
</div>
<div class="text-indigo-500">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center">
<div class="flex-1">
<p class="text-sm font-medium text-gray-500">Crash-Free Rate</p>
<p class="mt-1 text-3xl font-semibold text-gray-900">{{printf "%.1f" .Overview.CrashFreeRate}}%</p>
</div>
<div class="{{if ge .Overview.CrashFreeRate 99.0}}text-green-500{{else if ge .Overview.CrashFreeRate 95.0}}text-yellow-500{{else}}text-red-500{{end}}">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center">
<div class="flex-1">
<p class="text-sm font-medium text-gray-500">Total Crashes</p>
<p class="mt-1 text-3xl font-semibold text-gray-900">{{.Overview.TotalCrashes}}</p>
</div>
<div class="{{if eq .Overview.TotalCrashes 0}}text-green-500{{else}}text-red-500{{end}}">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
</div>
{{if gt .Overview.TotalCrashes 0}}
<a href="/apps/{{.App.ID}}/crashes" class="mt-2 text-sm text-indigo-600 hover:text-indigo-700">
View crash reports →
</a>
{{end}}
</div>
</div>
<!-- Charts Section -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- DAU Chart -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Daily Active Users</h3>
<div id="dau-chart" class="h-64"
hx-get="/apps/{{.App.ID}}/partials/chart-dau?days={{.Days}}"
hx-trigger="load">
<div class="flex items-center justify-center h-full text-gray-400">
<svg class="animate-spin h-8 w-8" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
</div>
</div>
<!-- Sessions Chart -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Sessions</h3>
<div id="sessions-chart" class="h-64"
hx-get="/apps/{{.App.ID}}/partials/chart-sessions?days={{.Days}}"
hx-trigger="load">
<div class="flex items-center justify-center h-full text-gray-400">
<svg class="animate-spin h-8 w-8" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
</div>
</div>
</div>
<!-- Event Types -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Event Distribution</h3>
{{if .EventStats}}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Event Type</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Count</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Unique Devices</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{{range .EventStats}}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{.EventType}}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-right">{{.Count}}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-right">{{.UniqueDevices}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="text-gray-500 text-center py-8">No events recorded yet.</p>
{{end}}
</div>
<!-- Recent Crashes -->
{{if .Crashes}}
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">Recent Crashes</h3>
<a href="/apps/{{.App.ID}}/crashes" class="text-sm text-indigo-600 hover:text-indigo-700">
View all →
</a>
</div>
<div class="space-y-4">
{{range .Crashes}}
<div class="flex items-start p-4 bg-red-50 border border-red-100 rounded-lg">
<div class="flex-shrink-0">
<svg class="w-5 h-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<div class="ml-3 flex-1">
<p class="text-sm font-medium text-red-800">{{.CrashType}}: {{.Message}}</p>
<div class="mt-1 flex items-center space-x-4 text-xs text-red-600">
<span>{{.OccurrenceCount}} occurrences</span>
<span>Last seen: {{.LastSeen}}</span>
</div>
</div>
<span class="ml-4 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{if eq .Status "open"}}bg-red-100 text-red-800{{else if eq .Status "resolved"}}bg-green-100 text-green-800{{else}}bg-gray-100 text-gray-800{{end}}">
{{.Status}}
</span>
</div>
{{end}}
</div>
</div>
{{end}}
</div>
{{end}}

View File

@@ -0,0 +1,148 @@
{{define "content"}}
<div class="mb-6">
<a href="/apps/{{.App.ID}}/analytics" class="inline-flex items-center text-sm text-gray-500 hover:text-gray-700">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back to Analytics
</a>
</div>
<!-- Header -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<div class="w-12 h-12 bg-red-100 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<div>
<h1 class="text-2xl font-bold text-gray-900">Crash Reports</h1>
<p class="text-gray-500">{{.App.Name}}</p>
</div>
</div>
<!-- Status Filter -->
<div class="flex items-center space-x-2">
<a href="/apps/{{.App.ID}}/crashes?status=open"
class="px-4 py-2 text-sm font-medium rounded-lg {{if eq .Status "open"}}bg-red-100 text-red-700{{else}}text-gray-500 hover:bg-gray-100{{end}}">
Open
</a>
<a href="/apps/{{.App.ID}}/crashes?status=resolved"
class="px-4 py-2 text-sm font-medium rounded-lg {{if eq .Status "resolved"}}bg-green-100 text-green-700{{else}}text-gray-500 hover:bg-gray-100{{end}}">
Resolved
</a>
<a href="/apps/{{.App.ID}}/crashes?status=ignored"
class="px-4 py-2 text-sm font-medium rounded-lg {{if eq .Status "ignored"}}bg-gray-200 text-gray-700{{else}}text-gray-500 hover:bg-gray-100{{end}}">
Ignored
</a>
</div>
</div>
</div>
<!-- Crashes List -->
{{if .Crashes}}
<div class="space-y-4">
{{range .Crashes}}
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 hover:border-gray-300 transition-colors">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center space-x-2 mb-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
{{.CrashType}}
</span>
<span class="text-xs text-gray-500">{{.OccurrenceCount}} occurrences</span>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">{{.Message}}</h3>
{{if .SampleStackTrace}}
<pre class="mt-3 p-3 bg-gray-50 rounded-lg text-xs text-gray-600 overflow-x-auto max-h-32">{{.SampleStackTrace}}</pre>
{{end}}
<div class="mt-3 flex items-center space-x-4 text-xs text-gray-500">
<span>First seen: {{.FirstSeen}}</span>
<span>Last seen: {{.LastSeen}}</span>
{{if .AffectedVersions}}
<span>Versions: {{range $i, $v := .AffectedVersions}}{{if $i}}, {{end}}{{$v}}{{end}}</span>
{{end}}
</div>
</div>
<div class="ml-6 flex flex-col items-end space-y-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded text-xs font-medium {{if eq .Status "open"}}bg-red-100 text-red-800{{else if eq .Status "resolved"}}bg-green-100 text-green-800{{else}}bg-gray-100 text-gray-800{{end}}">
{{.Status}}
</span>
<div class="flex space-x-2">
{{if eq .Status "open"}}
<button hx-post="/apps/{{$.App.ID}}/crashes/{{.ID}}/resolve"
hx-swap="outerHTML"
hx-target="closest div.bg-white"
class="text-xs text-green-600 hover:text-green-700 font-medium">
Mark Resolved
</button>
<button hx-post="/apps/{{$.App.ID}}/crashes/{{.ID}}/ignore"
hx-swap="outerHTML"
hx-target="closest div.bg-white"
class="text-xs text-gray-500 hover:text-gray-700 font-medium">
Ignore
</button>
{{else if eq .Status "resolved"}}
<button hx-post="/apps/{{$.App.ID}}/crashes/{{.ID}}/reopen"
hx-swap="outerHTML"
hx-target="closest div.bg-white"
class="text-xs text-red-600 hover:text-red-700 font-medium">
Reopen
</button>
{{else}}
<button hx-post="/apps/{{$.App.ID}}/crashes/{{.ID}}/reopen"
hx-swap="outerHTML"
hx-target="closest div.bg-white"
class="text-xs text-red-600 hover:text-red-700 font-medium">
Reopen
</button>
{{end}}
</div>
</div>
</div>
</div>
{{end}}
</div>
<!-- Pagination -->
{{if gt .Pagination.TotalPages 1}}
<div class="mt-6 flex items-center justify-between">
<div class="text-sm text-gray-500">
Showing {{add (mul (sub .Pagination.Page 1) .Pagination.Limit) 1}} to {{min (mul .Pagination.Page .Pagination.Limit) .Pagination.Total}} of {{.Pagination.Total}} crashes
</div>
<nav class="flex space-x-2">
{{if gt .Pagination.Page 1}}
<a href="/apps/{{.App.ID}}/crashes?status={{.Status}}&page={{sub .Pagination.Page 1}}"
class="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
Previous
</a>
{{end}}
{{if lt .Pagination.Page .Pagination.TotalPages}}
<a href="/apps/{{.App.ID}}/crashes?status={{.Status}}&page={{add .Pagination.Page 1}}"
class="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
Next
</a>
{{end}}
</nav>
</div>
{{end}}
{{else}}
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
<svg class="mx-auto h-12 w-12 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900">No crashes</h3>
<p class="mt-2 text-sm text-gray-500">
{{if eq .Status "open"}}
No open crash reports. Your app is running smoothly!
{{else if eq .Status "resolved"}}
No resolved crashes to show.
{{else}}
No ignored crashes.
{{end}}
</p>
</div>
{{end}}
{{end}}

View File

@@ -0,0 +1,63 @@
{{if .Items}}
{{range .Items}}
<a href="/admin/review/{{.Version.ID}}" class="block px-6 py-4 hover:bg-gray-50 transition-colors">
<div class="flex items-center justify-between">
<div class="flex items-center min-w-0">
<div class="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
</svg>
</div>
<div class="ml-4 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">{{.App.Name}}</p>
<p class="text-sm text-gray-500 truncate">{{.App.PackageID}} - v{{.Version.VersionName}}</p>
</div>
</div>
<div class="flex items-center space-x-4">
<div class="text-right">
<p class="text-sm text-gray-500">{{.DeveloperName}}</p>
<p class="text-xs text-gray-400">{{.Version.CreatedAt.Format "Jan 2, 2006"}}</p>
</div>
<span class="px-2 py-1 text-xs font-medium rounded bg-yellow-100 text-yellow-800">In Review</span>
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</div>
</div>
</a>
{{end}}
{{if gt .Pagination.TotalPages 1}}
<div class="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
<p class="text-sm text-gray-500">
Showing {{add (mul (sub .Pagination.Page 1) .Pagination.Limit) 1}} to {{min (mul .Pagination.Page .Pagination.Limit) .Pagination.Total}} of {{.Pagination.Total}} results
</p>
<div class="flex space-x-2">
{{if gt .Pagination.Page 1}}
<button
hx-get="/admin/partials/review-queue?page={{sub .Pagination.Page 1}}"
hx-target="#review-queue"
class="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-50">
Previous
</button>
{{end}}
{{if lt .Pagination.Page .Pagination.TotalPages}}
<button
hx-get="/admin/partials/review-queue?page={{add .Pagination.Page 1}}"
hx-target="#review-queue"
class="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-50">
Next
</button>
{{end}}
</div>
</div>
{{end}}
{{else}}
<div class="px-6 py-12 text-center">
<svg class="w-12 h-12 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p class="text-gray-500">No apps pending review.</p>
<p class="text-sm text-gray-400 mt-1">All caught up!</p>
</div>
{{end}}

View File

@@ -0,0 +1,43 @@
{{define "validation_results"}}
{{if .Valid}}
<div class="flex items-center text-green-600 mb-4">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Package is valid
</div>
{{else}}
<div class="flex items-center text-red-600 mb-4">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Package validation failed
</div>
{{end}}
{{if .Flags}}
<div class="space-y-3">
{{range .Flags}}
<div class="flex items-start p-3 rounded-lg {{if eq .Severity "critical"}}bg-red-50 border border-red-200{{else if eq .Severity "warning"}}bg-yellow-50 border border-yellow-200{{else}}bg-blue-50 border border-blue-200{{end}}">
<span class="px-2 py-0.5 text-xs font-medium rounded {{if eq .Severity "critical"}}bg-red-100 text-red-800{{else if eq .Severity "warning"}}bg-yellow-100 text-yellow-800{{else}}bg-blue-100 text-blue-800{{end}}">
{{.Severity}}
</span>
<div class="ml-3">
<p class="text-sm font-medium text-gray-900">{{.Reason}}</p>
{{if .File}}
<p class="text-xs text-gray-500 mt-1">{{.File}}{{if .Line}}:{{.Line}}{{end}}</p>
{{end}}
</div>
</div>
{{end}}
</div>
{{else}}
<p class="text-sm text-gray-500">No issues found.</p>
{{end}}
{{if .AutoApprovable}}
<div class="mt-4 pt-4 border-t border-gray-200">
<p class="text-sm text-green-600">Package is auto-approvable</p>
</div>
{{end}}
{{end}}

View File

@@ -1,421 +0,0 @@
{
"name": "Lua Sandbox Security Tests",
"summary": {
"failed": 0,
"passed": 82,
"total": 82
},
"tests": [
{
"duration_ms": 0,
"name": "DangerousGlobalsRemoved",
"status": "passed"
},
{
"duration_ms": 0,
"name": "BytecodeRejected",
"status": "passed"
},
{
"duration_ms": 2,
"name": "MemoryLimitEnforced",
"status": "passed"
},
{
"duration_ms": 0,
"name": "CPULimitEnforced",
"status": "passed"
},
{
"duration_ms": 0,
"name": "MetatableProtected",
"status": "passed"
},
{
"duration_ms": 0,
"name": "SafeOperationsWork",
"status": "passed"
},
{
"duration_ms": 0,
"name": "StringDumpRemoved",
"status": "passed"
},
{
"duration_ms": 0,
"name": "MemoryTracking",
"status": "passed"
},
{
"duration_ms": 0,
"name": "InstructionCounting",
"status": "passed"
},
{
"duration_ms": 0,
"name": "MultipleLoads",
"status": "passed"
},
{
"duration_ms": 0,
"name": "ErrorRecovery",
"status": "passed"
},
{
"duration_ms": 0,
"name": "NormalPermissionAutoGranted",
"status": "passed"
},
{
"duration_ms": 0,
"name": "DangerousPermissionRequiresGrant",
"status": "passed"
},
{
"duration_ms": 0,
"name": "SignaturePermissionSystemOnly",
"status": "passed"
},
{
"duration_ms": 106,
"name": "UserGestureTracking",
"status": "passed"
},
{
"duration_ms": 0,
"name": "UndeclaredPermissionDenied",
"status": "passed"
},
{
"duration_ms": 0,
"name": "SystemAppGetsDangerousAuto",
"status": "passed"
},
{
"duration_ms": 0,
"name": "PermissionCategoryCheck",
"status": "passed"
},
{
"duration_ms": 0,
"name": "AuditLogBasic",
"status": "passed"
},
{
"duration_ms": 0,
"name": "AuditLogRingBuffer",
"status": "passed"
},
{
"duration_ms": 13,
"name": "AuditLogThreadSafe",
"status": "passed"
},
{
"duration_ms": 0,
"name": "RateLimiterBasic",
"status": "passed"
},
{
"duration_ms": 0,
"name": "RateLimiterExhaustion",
"status": "passed"
},
{
"duration_ms": 17,
"name": "RateLimiterRefill",
"status": "passed"
},
{
"duration_ms": 0,
"name": "RateLimiterAppIsolation",
"status": "passed"
},
{
"duration_ms": 0,
"name": "RateLimiterReset",
"status": "passed"
},
{
"duration_ms": 0,
"name": "RateLimiterNoConfig",
"status": "passed"
},
{
"duration_ms": 0,
"name": "PathRejectsTraversal",
"status": "passed"
},
{
"duration_ms": 0,
"name": "PathRejectsAbsolute",
"status": "passed"
},
{
"duration_ms": 0,
"name": "PathAcceptsValid",
"status": "passed"
},
{
"duration_ms": 0,
"name": "ModuleNameValidation",
"status": "passed"
},
{
"duration_ms": 0,
"name": "ModuleToPath",
"status": "passed"
},
{
"duration_ms": 0,
"name": "SafeRequireLoads",
"status": "passed"
},
{
"duration_ms": 0,
"name": "SafeRequireCaches",
"status": "passed"
},
{
"duration_ms": 0,
"name": "SafeRequireRejectsInvalid",
"status": "passed"
},
{
"duration_ms": 108,
"name": "SetTimeoutFires",
"status": "passed"
},
{
"duration_ms": 234,
"name": "SetIntervalFires",
"status": "passed"
},
{
"duration_ms": 158,
"name": "ClearTimeoutCancels",
"status": "passed"
},
{
"duration_ms": 158,
"name": "ClearIntervalCancels",
"status": "passed"
},
{
"duration_ms": 0,
"name": "TimerLimitEnforced",
"status": "passed"
},
{
"duration_ms": 0,
"name": "ClearAppTimersCleanup",
"status": "passed"
},
{
"duration_ms": 63,
"name": "MinIntervalEnforced",
"status": "passed"
},
{
"duration_ms": 0,
"name": "JsonDecodeValid",
"status": "passed"
},
{
"duration_ms": 0,
"name": "JsonDecodeRejectsDeep",
"status": "passed"
},
{
"duration_ms": 0,
"name": "JsonEncodeValid",
"status": "passed"
},
{
"duration_ms": 0,
"name": "JsonEncodeDetectsCycles",
"status": "passed"
},
{
"duration_ms": 0,
"name": "JsonRejectsTooLarge",
"status": "passed"
},
{
"duration_ms": 0,
"name": "CryptoRandomBytes",
"status": "passed"
},
{
"duration_ms": 0,
"name": "CryptoHashSHA256",
"status": "passed"
},
{
"duration_ms": 0,
"name": "CryptoHMAC",
"status": "passed"
},
{
"duration_ms": 0,
"name": "SecureMathRandom",
"status": "passed"
},
{
"duration_ms": 1,
"name": "VirtualFSReadWrite",
"status": "passed"
},
{
"duration_ms": 0,
"name": "VirtualFSBlocksTraversal",
"status": "passed"
},
{
"duration_ms": 0,
"name": "VirtualFSEnforcesQuota",
"status": "passed"
},
{
"duration_ms": 0,
"name": "VirtualFSCleansUpTemp",
"status": "passed"
},
{
"duration_ms": 1,
"name": "VirtualFSList",
"status": "passed"
},
{
"duration_ms": 4,
"name": "VirtualFSStat",
"status": "passed"
},
{
"duration_ms": 1,
"name": "VirtualFSLuaIntegration",
"status": "passed"
},
{
"duration_ms": 1,
"name": "VirtualFSMaxFileSize",
"status": "passed"
},
{
"duration_ms": 16,
"name": "DatabaseCreatesTables",
"status": "passed"
},
{
"duration_ms": 13,
"name": "DatabasePreparedStatements",
"status": "passed"
},
{
"duration_ms": 1,
"name": "DatabaseBlocksAttach",
"status": "passed"
},
{
"duration_ms": 1,
"name": "DatabaseBlocksDangerousPragma",
"status": "passed"
},
{
"duration_ms": 16,
"name": "DatabaseMultiple",
"status": "passed"
},
{
"duration_ms": 0,
"name": "DatabaseLuaIntegration",
"status": "passed"
},
{
"duration_ms": 0,
"name": "DatabaseInvalidNames",
"status": "passed"
},
{
"duration_ms": 25,
"name": "DatabaseLastInsertAndChanges",
"status": "passed"
},
{
"duration_ms": 0,
"name": "NetworkBlocksPrivateIP",
"status": "passed"
},
{
"duration_ms": 0,
"name": "NetworkBlocksPlainHttp",
"status": "passed"
},
{
"duration_ms": 0,
"name": "NetworkRequiresHttps",
"status": "passed"
},
{
"duration_ms": 0,
"name": "NetworkEnforcesDomainWhitelist",
"status": "passed"
},
{
"duration_ms": 0,
"name": "NetworkUrlParsing",
"status": "passed"
},
{
"duration_ms": 0,
"name": "NetworkBlocksMetadata",
"status": "passed"
},
{
"duration_ms": 0,
"name": "NetworkRequestLimits",
"status": "passed"
},
{
"duration_ms": 0,
"name": "NetworkLuaIntegration",
"status": "passed"
},
{
"duration_ms": 0,
"name": "WebSocketUrlValidation",
"status": "passed"
},
{
"duration_ms": 0,
"name": "WebSocketConnectionLimits",
"status": "passed"
},
{
"duration_ms": 0,
"name": "WebSocketBlocksPrivateIP",
"status": "passed"
},
{
"duration_ms": 0,
"name": "WebSocketDomainWhitelist",
"status": "passed"
},
{
"duration_ms": 0,
"name": "WebSocketMessageLimits",
"status": "passed"
},
{
"duration_ms": 0,
"name": "WebSocketCloseAll",
"status": "passed"
},
{
"duration_ms": 0,
"name": "WebSocketLuaIntegration",
"status": "passed"
}
],
"timestamp": "2026-01-18T14:29:44Z"
}