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
|
.cxx
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/designer/test/*test_result.txt
|
/designer/test/*test_result.txt
|
||||||
|
/sandbox-test/test_results.json
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/omixlab/mosis-portal/pkg/mospkg"
|
"omixlab.com/mosis-portal/pkg/mospkg"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BuildCmd returns the build command
|
// BuildCmd returns the build command
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/omixlab/mosis-portal/pkg/mospkg"
|
"omixlab.com/mosis-portal/pkg/mospkg"
|
||||||
)
|
)
|
||||||
|
|
||||||
// InitCmd returns the init command
|
// InitCmd returns the init command
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
|
||||||
"github.com/omixlab/mosis-portal/pkg/mospkg"
|
"omixlab.com/mosis-portal/pkg/mospkg"
|
||||||
)
|
)
|
||||||
|
|
||||||
// KeysCmd returns the keys command
|
// KeysCmd returns the keys command
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
|
||||||
"github.com/omixlab/mosis-portal/pkg/mospkg"
|
"omixlab.com/mosis-portal/pkg/mospkg"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PublishCmd returns the publish command
|
// PublishCmd returns the publish command
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
|
||||||
"github.com/omixlab/mosis-portal/pkg/mospkg"
|
"omixlab.com/mosis-portal/pkg/mospkg"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RunCmd returns the run command
|
// RunCmd returns the run command
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/omixlab/mosis-portal/pkg/mospkg"
|
"omixlab.com/mosis-portal/pkg/mospkg"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SignCmd returns the sign command
|
// SignCmd returns the sign command
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
|
||||||
"github.com/omixlab/mosis-portal/pkg/mospkg"
|
"omixlab.com/mosis-portal/pkg/mospkg"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StatusCmd returns the status command
|
// StatusCmd returns the status command
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/omixlab/mosis-portal/pkg/mospkg"
|
"omixlab.com/mosis-portal/pkg/mospkg"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ValidateCmd returns the validate command
|
// ValidateCmd returns the validate command
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
|
||||||
"github.com/omixlab/mosis-portal/cmd/mosis/cmd"
|
"omixlab.com/mosis-portal/cmd/mosis/cmd"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/omixlab/mosis-portal/internal/api"
|
"omixlab.com/mosis-portal/internal/api"
|
||||||
"github.com/omixlab/mosis-portal/internal/config"
|
"omixlab.com/mosis-portal/internal/config"
|
||||||
"github.com/omixlab/mosis-portal/internal/database"
|
"omixlab.com/mosis-portal/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module github.com/omixlab/mosis-portal
|
module omixlab.com/mosis-portal
|
||||||
|
|
||||||
go 1.22
|
go 1.22
|
||||||
|
|
||||||
@@ -9,6 +9,8 @@ require (
|
|||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/spf13/cobra v1.8.0
|
github.com/spf13/cobra v1.8.0
|
||||||
github.com/spf13/viper v1.18.2
|
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/crypto v0.21.0
|
||||||
golang.org/x/image v0.15.0
|
golang.org/x/image v0.15.0
|
||||||
golang.org/x/oauth2 v0.18.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/go-chi/chi/v5"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/omixlab/mosis-portal/internal/api/middleware"
|
"omixlab.com/mosis-portal/internal/api/middleware"
|
||||||
"github.com/omixlab/mosis-portal/internal/database"
|
"omixlab.com/mosis-portal/internal/database"
|
||||||
"github.com/omixlab/mosis-portal/internal/storage"
|
"omixlab.com/mosis-portal/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AppHandler handles app-related endpoints
|
// AppHandler handles app-related endpoints
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/omixlab/mosis-portal/internal/api/middleware"
|
"omixlab.com/mosis-portal/internal/api/middleware"
|
||||||
"github.com/omixlab/mosis-portal/internal/auth"
|
"omixlab.com/mosis-portal/internal/auth"
|
||||||
"github.com/omixlab/mosis-portal/internal/database"
|
"omixlab.com/mosis-portal/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthHandler handles authentication endpoints
|
// AuthHandler handles authentication endpoints
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/omixlab/mosis-portal/internal/database"
|
"omixlab.com/mosis-portal/internal/database"
|
||||||
"github.com/omixlab/mosis-portal/internal/storage"
|
"omixlab.com/mosis-portal/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StoreHandler handles public store endpoints
|
// 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"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/omixlab/mosis-portal/internal/auth"
|
"omixlab.com/mosis-portal/internal/auth"
|
||||||
"github.com/omixlab/mosis-portal/internal/database"
|
"omixlab.com/mosis-portal/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey string
|
type contextKey string
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
chimw "github.com/go-chi/chi/v5/middleware"
|
chimw "github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/omixlab/mosis-portal/internal/api/handlers"
|
"omixlab.com/mosis-portal/internal/api/handlers"
|
||||||
"github.com/omixlab/mosis-portal/internal/api/middleware"
|
"omixlab.com/mosis-portal/internal/api/middleware"
|
||||||
"github.com/omixlab/mosis-portal/internal/auth"
|
"omixlab.com/mosis-portal/internal/auth"
|
||||||
"github.com/omixlab/mosis-portal/internal/config"
|
"omixlab.com/mosis-portal/internal/config"
|
||||||
"github.com/omixlab/mosis-portal/internal/database"
|
"omixlab.com/mosis-portal/internal/database"
|
||||||
"github.com/omixlab/mosis-portal/internal/storage"
|
"omixlab.com/mosis-portal/internal/storage"
|
||||||
"github.com/omixlab/mosis-portal/internal/web"
|
"omixlab.com/mosis-portal/internal/telemetry"
|
||||||
|
"omixlab.com/mosis-portal/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewRouter creates and configures the HTTP router
|
// 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)
|
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
|
// Initialize auth components
|
||||||
jwtManager := auth.NewJWTManager(cfg.JWTSecret)
|
jwtManager := auth.NewJWTManager(cfg.JWTSecret)
|
||||||
oauthManager := auth.NewOAuthManager(
|
oauthManager := auth.NewOAuthManager(
|
||||||
@@ -43,6 +51,8 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
|
|||||||
authHandler := handlers.NewAuthHandler(oauthManager, jwtManager, db)
|
authHandler := handlers.NewAuthHandler(oauthManager, jwtManager, db)
|
||||||
appHandler := handlers.NewAppHandler(db, store)
|
appHandler := handlers.NewAppHandler(db, store)
|
||||||
storeHandler := handlers.NewStoreHandler(db, store)
|
storeHandler := handlers.NewStoreHandler(db, store)
|
||||||
|
adminHandler := handlers.NewAdminHandler(db, store)
|
||||||
|
telemetryHandler := handlers.NewTelemetryHandler(db, telemetrySvc)
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
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
|
// Package upload
|
||||||
r.Post("/{versionID}/upload", appHandler.UploadPackage)
|
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
|
// 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)
|
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.Route("/telemetry", func(r chi.Router) {
|
||||||
r.Post("/events", handlers.NotImplemented)
|
r.Post("/events", telemetryHandler.RecordEvents)
|
||||||
r.Post("/crash", handlers.NotImplemented)
|
r.Post("/crash", telemetryHandler.RecordCrash)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Admin routes (htmx UI) - requires auth
|
// Admin API routes (JSON responses)
|
||||||
r.Route("/admin", func(r chi.Router) {
|
r.Route("/api/admin", func(r chi.Router) {
|
||||||
r.Use(authMiddleware.RequireAuth)
|
r.Use(authMiddleware.RequireAuth)
|
||||||
r.Get("/", handlers.NotImplemented)
|
r.Get("/stats", adminHandler.Dashboard)
|
||||||
r.Get("/review-queue", handlers.NotImplemented)
|
r.Get("/review-queue", adminHandler.ReviewQueue)
|
||||||
r.Get("/review/{versionID}", handlers.NotImplemented)
|
r.Get("/review/{versionID}", adminHandler.ReviewDetail)
|
||||||
r.Post("/review/{versionID}/approve", handlers.NotImplemented)
|
r.Post("/review/{versionID}/approve", adminHandler.ApproveVersion)
|
||||||
r.Post("/review/{versionID}/reject", handlers.NotImplemented)
|
r.Post("/review/{versionID}/reject", adminHandler.RejectVersion)
|
||||||
|
r.Get("/review/{versionID}/validate", adminHandler.ValidatePackage)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Web UI routes (htmx + Go templates)
|
// Web UI routes (htmx + Go templates)
|
||||||
@@ -142,6 +166,8 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Warning: Failed to initialize web handler: %v", err)
|
log.Printf("Warning: Failed to initialize web handler: %v", err)
|
||||||
} else {
|
} else {
|
||||||
|
webHandler.SetStorage(store)
|
||||||
|
webHandler.SetTelemetry(telemetrySvc)
|
||||||
sessionMW := web.NewSessionMiddleware(db, cfg.JWTSecret)
|
sessionMW := web.NewSessionMiddleware(db, cfg.JWTSecret)
|
||||||
|
|
||||||
// Public web pages
|
// Public web pages
|
||||||
@@ -160,9 +186,19 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
|
|||||||
r.Get("/dashboard", webHandler.Dashboard)
|
r.Get("/dashboard", webHandler.Dashboard)
|
||||||
r.Get("/apps/new", webHandler.AppNew)
|
r.Get("/apps/new", webHandler.AppNew)
|
||||||
r.Get("/apps/{appID}", webHandler.AppDetail)
|
r.Get("/apps/{appID}", webHandler.AppDetail)
|
||||||
|
r.Get("/apps/{appID}/analytics", webHandler.AppAnalytics)
|
||||||
|
r.Get("/apps/{appID}/crashes", webHandler.AppCrashes)
|
||||||
|
|
||||||
// htmx partials
|
// htmx partials
|
||||||
r.Get("/partials/apps", webHandler.AppListPartial)
|
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)
|
// 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
|
// Static file servers for packages and assets
|
||||||
// Downloads - serve package files with proper headers
|
// Downloads - serve package files with proper headers
|
||||||
r.Handle("/downloads/*", http.StripPrefix("/downloads/",
|
r.Handle("/downloads/*", http.StripPrefix("/downloads/",
|
||||||
|
|||||||
@@ -878,3 +878,209 @@ func (db *DB) GetPublishedVersionByCode(ctx context.Context, packageID string, v
|
|||||||
|
|
||||||
return scanVersion(row)
|
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)
|
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
|
// copyFile copies a file from src to dst
|
||||||
func copyFile(src, dst string) error {
|
func copyFile(src, dst string) error {
|
||||||
in, err := os.Open(src)
|
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 (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"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
|
// Handler handles web page requests
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
db *database.DB
|
db *database.DB
|
||||||
templates *Templates
|
templates *Templates
|
||||||
|
store *storage.Storage
|
||||||
|
review *review.Service
|
||||||
|
telemetry *telemetry.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a new web handler
|
// NewHandler creates a new web handler
|
||||||
@@ -22,9 +29,20 @@ func NewHandler(db *database.DB) (*Handler, error) {
|
|||||||
return &Handler{
|
return &Handler{
|
||||||
db: db,
|
db: db,
|
||||||
templates: templates,
|
templates: templates,
|
||||||
|
review: review.New(db),
|
||||||
}, nil
|
}, 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
|
// PageData is the base data structure for all pages
|
||||||
type PageData struct {
|
type PageData struct {
|
||||||
Title string
|
Title string
|
||||||
@@ -192,3 +210,374 @@ func getDeveloperFromContext(r *http.Request) *database.Developer {
|
|||||||
developer, _ := r.Context().Value("developer").(*database.Developer)
|
developer, _ := r.Context().Value("developer").(*database.Developer)
|
||||||
return 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"
|
"encoding/gob"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/omixlab/mosis-portal/internal/database"
|
"omixlab.com/mosis-portal/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|||||||
@@ -17,6 +17,28 @@ type Templates struct {
|
|||||||
templates map[string]*template.Template
|
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
|
// NewTemplates creates and parses all templates
|
||||||
func NewTemplates() (*Templates, error) {
|
func NewTemplates() (*Templates, error) {
|
||||||
t := &Templates{
|
t := &Templates{
|
||||||
@@ -47,7 +69,7 @@ func NewTemplates() (*Templates, error) {
|
|||||||
files := append([]string{pageFile}, baseFiles...)
|
files := append([]string{pageFile}, baseFiles...)
|
||||||
|
|
||||||
// Read and parse all files
|
// Read and parse all files
|
||||||
tmpl := template.New(filepath.Base(pageFile))
|
tmpl := template.New(filepath.Base(pageFile)).Funcs(templateFuncs)
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
content, err := templateFS.ReadFile(file)
|
content, err := templateFS.ReadFile(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -70,7 +92,7 @@ func NewTemplates() (*Templates, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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