Compare commits
5 Commits
cf9f42b66d
...
9ccdf846f0
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ccdf846f0 | |||
| 8cb3cf769d | |||
| 94a573f218 | |||
| a5aa3cc9d7 | |||
| fbcb5c9543 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ build
|
||||
.cxx
|
||||
.DS_Store
|
||||
/designer/test/*test_result.txt
|
||||
/sandbox-test/test_results.json
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
226
portal/internal/api/handlers/admin.go
Normal file
226
portal/internal/api/handlers/admin.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
269
portal/internal/api/handlers/telemetry.go
Normal file
269
portal/internal/api/handlers/telemetry.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
405
portal/internal/review/service.go
Normal file
405
portal/internal/review/service.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
666
portal/internal/telemetry/telemetry.go
Normal file
666
portal/internal/telemetry/telemetry.go
Normal 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(¤tDAU)
|
||||
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(¤tCrashes)
|
||||
|
||||
// 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
304
portal/internal/web/docs.go
Normal 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>© 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>`
|
||||
806
portal/internal/web/docs/api/lua-api.md
Normal file
806
portal/internal/web/docs/api/lua-api.md
Normal 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
|
||||
341
portal/internal/web/docs/api/manifest.md
Normal file
341
portal/internal/web/docs/api/manifest.md
Normal 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
|
||||
576
portal/internal/web/docs/cli.md
Normal file
576
portal/internal/web/docs/cli.md
Normal 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
|
||||
267
portal/internal/web/docs/faq.md
Normal file
267
portal/internal/web/docs/faq.md
Normal 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
|
||||
190
portal/internal/web/docs/getting-started.md
Normal file
190
portal/internal/web/docs/getting-started.md
Normal 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)
|
||||
535
portal/internal/web/docs/guides/best-practices.md
Normal file
535
portal/internal/web/docs/guides/best-practices.md
Normal 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("&", "&")
|
||||
text = text:gsub("<", "<")
|
||||
text = text:gsub(">", ">")
|
||||
|
||||
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
|
||||
506
portal/internal/web/docs/guides/lua-scripting.md
Normal file
506
portal/internal/web/docs/guides/lua-scripting.md
Normal 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
|
||||
396
portal/internal/web/docs/guides/permissions.md
Normal file
396
portal/internal/web/docs/guides/permissions.md
Normal 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
|
||||
395
portal/internal/web/docs/guides/ui-design.md
Normal file
395
portal/internal/web/docs/guides/ui-design.md
Normal 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
|
||||
51
portal/internal/web/docs/index.md
Normal file
51
portal/internal/web/docs/index.md
Normal 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.
|
||||
469
portal/internal/web/docs/troubleshooting.md
Normal file
469
portal/internal/web/docs/troubleshooting.md
Normal 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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"encoding/gob"
|
||||
"net/http"
|
||||
|
||||
"github.com/omixlab/mosis-portal/internal/database"
|
||||
"omixlab.com/mosis-portal/internal/database"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
206
portal/internal/web/templates/pages/admin_review_detail.html
Normal file
206
portal/internal/web/templates/pages/admin_review_detail.html
Normal 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}}
|
||||
70
portal/internal/web/templates/pages/admin_review_queue.html
Normal file
70
portal/internal/web/templates/pages/admin_review_queue.html
Normal 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}}
|
||||
228
portal/internal/web/templates/pages/app_analytics.html
Normal file
228
portal/internal/web/templates/pages/app_analytics.html
Normal 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}}
|
||||
148
portal/internal/web/templates/pages/app_crashes.html
Normal file
148
portal/internal/web/templates/pages/app_crashes.html
Normal 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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user