diff --git a/.gitignore b/.gitignore index 8126414..45ca20a 100644 --- a/.gitignore +++ b/.gitignore @@ -338,3 +338,31 @@ ImageMagic/ EXE/NotForGit/ viewtube/ WebFixes/ + +## MacOS gitignore ## + +# General +.DS_Store +__MACOSX/ +.AppleDouble +.LSOverride +Icon[] + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk diff --git a/HttpTransit.cs b/HttpTransit.cs index 572a14f..c3dccc3 100644 --- a/HttpTransit.cs +++ b/HttpTransit.cs @@ -188,6 +188,13 @@ public void ProcessTransit() string RefererUri = ClientRequest.Headers["Referer"]; if (RefererUri == "") RefererUri = null; + //check for web snapshot viewer mode + if (Program.SnapshotViewerMode) + { + new WebOne.SnapshotViewer.WebSnapshotViewer(ClientRequest, ClientResponse, Log).Handle(RequestURL); + return; + } + //check for blacklisted URL if (CheckString(RequestURL.ToString(), ConfigFile.UrlBlackList)) { diff --git a/Program.cs b/Program.cs index 7cea989..21a639f 100644 --- a/Program.cs +++ b/Program.cs @@ -33,6 +33,11 @@ public static class Program public static string Protocols = "HTTP 1.1"; public static bool DaemonMode = false; + public static bool SnapshotViewerMode = false; + public static bool SnapshotViewerHeaded = false; + public static int JpegQuality = 85; + public static int StripHeight = 100; + public static int MinThreads = 1000; static bool ShutdownInitiated = false; static bool RebuildCA = false; @@ -253,6 +258,13 @@ static void Main(string[] args) return; } + //allow up to MinThreads concurrent requests without thread pool starvation + System.Threading.ThreadPool.SetMinThreads(MinThreads, MinThreads); + + //pre-warm the browser driver so the first snapshot request doesn't wait for it + if (SnapshotViewerMode) + WebOne.SnapshotViewer.ScreenshotEngine.EnsureContextAsync().GetAwaiter().GetResult(); + //start the server from 1 or 2 attempts for (int StartAttempts = 0; StartAttempts < 2; StartAttempts++) { @@ -340,6 +352,7 @@ public static void Shutdown(int Code = 0) ShutdownInitiated = true; if (Server != null && Server.Working) Server.Stop(); + if (SnapshotViewerMode) WebOne.SnapshotViewer.ScreenshotEngine.ShutdownAsync().GetAwaiter().GetResult(); if (!DaemonMode && !Environment.HasShutdownStarted && !ShutdownInitiated) try { @@ -438,6 +451,47 @@ private static void ProcessCommandLine(string[] args) case "--daemon": DaemonMode = true; break; + case "--web-snapshot-viewer": + SnapshotViewerMode = true; + Console.WriteLine("Web Snapshot Viewer mode enabled."); + break; + case "--snapshot-headed": + SnapshotViewerHeaded = true; + Console.WriteLine("Snapshot Viewer: browser will run in headed (visible) mode."); + break; + case "--quality": + if (int.TryParse(kvp.Value, out int q) && q >= 1 && q <= 100) + { + JpegQuality = q; + Console.WriteLine("JPEG quality set to {0}.", JpegQuality); + } + else + { + Console.WriteLine("Invalid --quality value: {0}. Expected a number between 1 and 100.", kvp.Value); + } + break; + case "--strip-size": + if (int.TryParse(kvp.Value, out int sh) && sh > 0) + { + StripHeight = sh; + Console.WriteLine("Strip height set to {0}px.", StripHeight); + } + else + { + Console.WriteLine("Invalid --strip-size value: {0}. Expected a positive number.", kvp.Value); + } + break; + case "--set-min-threads": + if (int.TryParse(kvp.Value, out int mt) && mt > 0) + { + MinThreads = mt; + Console.WriteLine("Min threads set to {0}.", MinThreads); + } + else + { + Console.WriteLine("Invalid --set-min-threads value: {0}. Expected a positive number.", kvp.Value); + } + break; case "--help": case "-?": case "/?": diff --git a/WebOne.csproj b/WebOne.csproj index f9d0eed..1e5407a 100644 --- a/WebOne.csproj +++ b/WebOne.csproj @@ -170,7 +170,10 @@ fi + + + @@ -191,6 +194,8 @@ fi + + diff --git a/tests/ScreenshotEngineTests.cs b/tests/ScreenshotEngineTests.cs new file mode 100644 index 0000000..b2a2d8f --- /dev/null +++ b/tests/ScreenshotEngineTests.cs @@ -0,0 +1,88 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Playwright; +using Xunit; +using Xunit.Abstractions; + +namespace SnapshotViewerTests +{ + public class ScreenshotEngineTests + { + private readonly ITestOutputHelper _output; + + public ScreenshotEngineTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public async Task Firefox_LaunchesHeadless() + { + using var playwright = await Playwright.CreateAsync(); + var browser = await playwright.Firefox.LaunchAsync(new BrowserTypeLaunchOptions + { + Headless = true + }); + + Assert.True(browser.IsConnected); + _output.WriteLine("Firefox launched OK."); + await browser.CloseAsync(); + } + + [Fact] + public async Task Firefox_NavigatesAndReturnsTitle() + { + using var playwright = await Playwright.CreateAsync(); + await using var browser = await playwright.Firefox.LaunchAsync(new BrowserTypeLaunchOptions + { + Headless = true + }); + + var page = await browser.NewPageAsync(); + await page.GotoAsync("https://example.com", new PageGotoOptions + { + WaitUntil = WaitUntilState.Load, + Timeout = 30000 + }); + + string title = await page.TitleAsync(); + _output.WriteLine($"Page title: {title}"); + + Assert.False(string.IsNullOrEmpty(title)); + await page.CloseAsync(); + } + + [Fact] + public async Task Firefox_TakesFullPageScreenshot() + { + using var playwright = await Playwright.CreateAsync(); + await using var browser = await playwright.Firefox.LaunchAsync(new BrowserTypeLaunchOptions + { + Headless = true + }); + + var page = await browser.NewPageAsync(new BrowserNewPageOptions + { + ViewportSize = new ViewportSize { Width = 1280, Height = 800 } + }); + + await page.GotoAsync("https://example.com", new PageGotoOptions + { + WaitUntil = WaitUntilState.Load, + Timeout = 30000 + }); + + byte[] jpg = await page.ScreenshotAsync(new PageScreenshotOptions + { + FullPage = true, + Type = ScreenshotType.Jpeg, + Quality = 85 + }); + + _output.WriteLine($"Screenshot size: {jpg.Length} bytes"); + + Assert.True(jpg.Length > 1000, "Screenshot should be larger than 1KB"); + await page.CloseAsync(); + } + } +} diff --git a/tests/SnapshotViewerTests.csproj b/tests/SnapshotViewerTests.csproj new file mode 100644 index 0000000..8f1ba1f --- /dev/null +++ b/tests/SnapshotViewerTests.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + diff --git a/web-snapshot-viewer/ARCHITECTURE.md b/web-snapshot-viewer/ARCHITECTURE.md new file mode 100644 index 0000000..14bdf6a --- /dev/null +++ b/web-snapshot-viewer/ARCHITECTURE.md @@ -0,0 +1,244 @@ +# Architecture: `--web-snapshot-viewer` + +## General flow + +``` +Browser → Proxy → [has dimensions?] + ├─ NO → Return "probe page" (minimal HTML with JS) + │ Browser runs JS → redirects with dimensions → Proxy + └─ YES → Playwright screenshot → HTML shell page with strip images +``` + +--- + +## File structure + +``` +./web-snapshot-viewer/ +├── WebSnapshotViewer.cs # HTTP router — dispatches all requests to the right handler +├── DimensionProbe.cs # Generates lightweight HTML to detect viewport w/h +├── ScreenshotEngine.cs # Playwright wrapper — screenshot, scroll, click +├── SnapshotPage.cs # Generates the shell page (strip IMGs + scripts) +├── StripManager.cs # Splits PNG into horizontal strips, hashes, encodes JPEG +├── BlankStrip.cs # Generates checkerboard placeholder GIF per session +├── ScrollHandler.cs # Scroll sync JS (inline) + /scroll-pos endpoint +├── ClickHandler.cs # Click overlay JS + /click endpoint +└── ClientStripManager.cs # JS helpers: sendCmd(), updateStrip(), reloadPage() +``` + +--- + +## Interception point in the proxy + +``` +HttpTransit → [SnapshotViewerMode?] + ├─ YES → WebSnapshotViewer.Handle(request) + └─ NO → normal proxy flow +``` + +`Program.cs` flags: +```csharp +public static bool SnapshotViewerMode = false; // --web-snapshot-viewer +public static bool SnapshotViewerHeaded = false; // --snapshot-headed +public static int JpegQuality = 85; // --quality +public static int StripHeight = 100; // --strip-size +public static int MinThreads = 1000; // --set-min-threads +``` + +Thread pool is raised at startup to avoid starvation (50 tabs × 6 connections = 300 concurrent requests): +```csharp +ThreadPool.SetMinThreads(MinThreads, MinThreads); +``` + +Browser driver is pre-warmed at startup so the first request doesn't wait: +```csharp +ScreenshotEngine.EnsureContextAsync().GetAwaiter().GetResult(); +``` + +--- + +## HTTP endpoints (all on `snapshot.webone.internal`) + +| Endpoint | Handler | Description | +|---|---|---| +| `GET /snap?url=...&w=...&h=...` | `HandleSnapshot` | Takes screenshot, returns shell page | +| `GET /strip?key=...&i=...&r=...` | `HandleStrip` | Serves a single JPEG strip | +| `GET /blank-strip?key=...` | `HandleBlankStrip` | Serves the placeholder GIF for unloaded strips | +| `GET /click?key=...&x=...&y=...` | `HandleClick` | Forwards click to Playwright, returns strip update JS | +| `GET /scroll-pos?key=...&y=...` | `HandleScrollPos` | Syncs scroll position to Playwright, returns 1×1 GIF | + +All other URLs → probe page (JS redirect with viewport dimensions). + +--- + +## Detailed flow + +### Step 1 — Probe page + +Browser requests any URL → proxy has no dimensions → returns: +```html + +``` +Browser runs JS and redirects to `/snap` with its actual viewport dimensions. + +### Step 2 — Snapshot + +`/snap?url=...&w=...&h=...` → check cache → if miss: +1. Playwright navigates to `url` with viewport `w × h` +2. Takes full-page PNG screenshot +3. `StripManager.CreateStrips()` splits PNG into horizontal strips of `StripHeight` px +4. Each strip is hashed (SHA256 of raw pixels) and encoded as JPEG RGB +5. A checkerboard placeholder GIF is generated for the session (`BlankStrip.Generate`) +6. `StripSet` stored in cache (key = `SHA256(url + width)`, TTL = 5 min) +7. Shell page returned + +--- + +## Strip system + +### StripData +```csharp +class StripData { + byte[] Hash; // SHA256 of raw pixels — used to detect changes after clicks + byte[] Jpeg; // JPEG-encoded bytes served directly to browser + string Revision; // Unix timestamp ms — appended to URL to bust browser cache + int Height; // Pixel height of this strip (last strip may be shorter) +} +``` + +### StripSet +```csharp +class StripSet { + StripData[] Strips; + int ImageWidth, ImageHeight, StripHeight; + int ViewportHeight, NumberStripsInViewport; + int LastScrollY; + byte[] BlankStripGif; // Per-session checkerboard GIF (same width as screenshot) + DateTime CreatedAt; +} +``` + +### JPEG encoding +Strips are encoded as **JPEG RGB** (`JpegEncodingColor.Rgb`) to avoid the YCbCr→RGB conversion crash in Safari 1 on Mac OS X 10.3 (`vec_ycc_rgb_convert` AltiVec bug). + +--- + +## Shell page + +The shell page is a plain HTML document that: +1. Has CSS `IMG { display:block; width:100% }` so strips fill the full width +2. Contains one `` per strip — only the first `NumberStripsInViewport + 2` get a real SRC; the rest get `/blank-strip?key=...` (lazy loading) +3. Has a hidden `"); + + sb.Append(ClickHandler.GetClickScript(sessionKey, strips.ImageWidth, strips.ImageHeight)); + sb.Append(""); + return sb.ToString(); + } + } +} diff --git a/web-snapshot-viewer/StripManager.cs b/web-snapshot-viewer/StripManager.cs new file mode 100644 index 0000000..36dbd6f --- /dev/null +++ b/web-snapshot-viewer/StripManager.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace WebOne.SnapshotViewer +{ + /// + /// A single horizontal strip of a page screenshot. + /// + class StripData + { + /// SHA256 of the strip's raw RGB pixels. Used to detect changes. + public byte[] Hash; + /// JPEG-encoded bytes of this strip. Served directly to the browser. + public byte[] Jpeg; + /// Timestamp string appended to the image URL to bust browser cache on change. + public string Revision; + /// Height of this strip in screenshot pixels. + public int Height; + } + + /// + /// The full set of strips for one page snapshot session. + /// + class StripSet + { + public StripData[] Strips; + public int ImageWidth; + public int ImageHeight; + public int StripHeight; + public int ViewportHeight; + public int NumberStripsInViewport; + public int LastScrollY; + public byte[] BlankStripGif; + public DateTime CreatedAt; + } + + /// + /// Splits PNG screenshots into horizontal strips, hashes each strip for change detection, + /// and encodes changed strips to JPEG for serving. + /// + static class StripManager + { + /// + /// Creates a from a full-page PNG screenshot. + /// + public static StripSet CreateStrips(byte[] png, int stripHeight, int viewportHeight = 0) + { + using var image = Image.Load(png); + int count = (int)Math.Ceiling((double)image.Height / stripHeight); + string rev = Revision(); + var strips = new StripData[count]; + + for (int i = 0; i < count; i++) + { + int y = i * stripHeight; + int h = Math.Min(stripHeight, image.Height - y); + using var strip = image.Clone(ctx => ctx.Crop(new Rectangle(0, y, image.Width, h))); + strips[i] = new StripData + { + Hash = Hash(strip), + Jpeg = EncodeJpeg(strip), + Revision = rev, + Height = h + }; + } + + return new StripSet + { + Strips = strips, + ImageWidth = image.Width, + ImageHeight = image.Height, + StripHeight = stripHeight, + ViewportHeight = viewportHeight, + NumberStripsInViewport = viewportHeight / stripHeight, + BlankStripGif = BlankStrip.Generate(image.Width, stripHeight), + CreatedAt = DateTime.UtcNow + }; + } + + /// + /// Compares a new PNG screenshot against an existing and updates + /// only the strips that changed. Unchanged strips keep their existing JPEG and revision. + /// + /// Indices of strips that changed. + public static List UpdateStrips(StripSet existing, byte[] newPng) + { + var changed = new List(); + using var image = Image.Load(newPng); + int count = (int)Math.Ceiling((double)image.Height / existing.StripHeight); + string rev = Revision(); + + // Resize strips array if page height changed + if (count != existing.Strips.Length) + { + var resized = new StripData[count]; + Array.Copy(existing.Strips, resized, Math.Min(existing.Strips.Length, count)); + for (int i = existing.Strips.Length; i < count; i++) + resized[i] = new StripData { Hash = Array.Empty(), Jpeg = Array.Empty(), Revision = "0" }; + existing.Strips = resized; + } + + for (int i = 0; i < count; i++) + { + int y = i * existing.StripHeight; + int h = Math.Min(existing.StripHeight, image.Height - y); + using var strip = image.Clone(ctx => ctx.Crop(new Rectangle(0, y, image.Width, h))); + byte[] newHash = Hash(strip); + + if (!HashEqual(existing.Strips[i]?.Hash, newHash)) + { + existing.Strips[i] = new StripData + { + Hash = newHash, + Jpeg = EncodeJpeg(strip), + Revision = rev, + Height = h + }; + changed.Add(i); + } + } + + existing.ImageWidth = image.Width; + existing.ImageHeight = image.Height; + return changed; + } + + private static byte[] Hash(Image image) + { + // Copy raw RGB pixel bytes and hash them — deterministic and lossless. + var pixelBytes = new byte[image.Width * image.Height * 3]; + image.CopyPixelDataTo(pixelBytes); + return SHA256.HashData(pixelBytes); + } + + private static byte[] EncodeJpeg(Image image) + { + using var ms = new MemoryStream(); + image.SaveAsJpeg(ms, new JpegEncoder { Quality = Program.JpegQuality, ColorType = JpegEncodingColor.Rgb }); + return ms.ToArray(); + } + + private static bool HashEqual(byte[] a, byte[] b) + { + if (a == null || b == null || a.Length != b.Length) return false; + for (int i = 0; i < a.Length; i++) + if (a[i] != b[i]) return false; + return true; + } + + private static string Revision() => + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(); + } +} diff --git a/web-snapshot-viewer/WebSnapshotViewer.cs b/web-snapshot-viewer/WebSnapshotViewer.cs new file mode 100644 index 0000000..349f94b --- /dev/null +++ b/web-snapshot-viewer/WebSnapshotViewer.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using System.Web; + +namespace WebOne.SnapshotViewer +{ + /// + /// Handles all HTTP requests when --web-snapshot-viewer mode is active. + /// Routes requests to the probe page, screenshot handler, strip server, or click handler. + /// + class WebSnapshotViewer + { + private static readonly Dictionary Cache = new(); + private static readonly TimeSpan CacheTTL = TimeSpan.FromMinutes(5); + + private readonly HttpRequest ClientRequest; + private readonly HttpResponse ClientResponse; + private readonly LogWriter Log; + + public WebSnapshotViewer(HttpRequest request, HttpResponse response, LogWriter log) + { + ClientRequest = request; + ClientResponse = response; + Log = log; + } + + public void Handle(Uri requestUrl) + { + if (requestUrl == null) + { + SendHtml(DimensionProbe.GetPage("about:blank")); + return; + } + + if (requestUrl.Host.Equals(DimensionProbe.MagicHost, StringComparison.OrdinalIgnoreCase)) + { + switch (requestUrl.AbsolutePath.ToLowerInvariant()) + { + case "/snap": HandleSnapshot(requestUrl); break; + case "/strip": HandleStrip(requestUrl); break; + case "/blank-strip": HandleBlankStrip(requestUrl); break; + case "/click": HandleClick(requestUrl); break; + case "/scroll-pos": HandleScrollPos(requestUrl); break; + default: + SendHtml("

Unknown endpoint.

"); + break; + } + } + else + { + Log.WriteLine(" [Snapshot] Probe for: {0}", requestUrl); + SendHtml(DimensionProbe.GetPage(requestUrl.ToString())); + } + } + + private void HandleSnapshot(Uri requestUrl) + { + var qs = HttpUtility.ParseQueryString(requestUrl.Query); + string targetUrl = qs["url"]; + string wStr = qs["w"]; + string hStr = qs["h"]; + + if (string.IsNullOrEmpty(targetUrl) || !int.TryParse(wStr, out int width) || !int.TryParse(hStr, out int height)) + { + SendHtml("

Invalid snapshot request.

"); + return; + } + + width = Math.Clamp(width, 320, 3840); + height = Math.Clamp(height, 200, 2160); + + string sessionKey = GetSessionKey(targetUrl, width); + Log.WriteLine(" [Snapshot] {0} @ {1}px wide", targetUrl, width); + + if (Cache.TryGetValue(sessionKey, out var existing) && DateTime.UtcNow - existing.CreatedAt < CacheTTL) + { + Log.WriteLine(" [Snapshot] Cache hit."); + SendHtml(SnapshotPage.GetShellPage(sessionKey, existing)); + return; + } + + byte[] png; + try + { + png = Task.Run(() => ScreenshotEngine.TakeScreenshot(sessionKey, targetUrl, width, height)) + .GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Log.WriteLine(" [Snapshot] Error: {0}", ex.Message); + SendHtml( + "

Screenshot failed

" + + "

" + HttpUtility.HtmlEncode(targetUrl) + "

" + + "
" + HttpUtility.HtmlEncode(ex.Message) + "
" + + ""); + return; + } + + var strips = StripManager.CreateStrips(png, Program.StripHeight, height); + Cache[sessionKey] = strips; + Log.WriteLine(" [Snapshot] {0} strips created ({1}x{2}px).", strips.Strips.Length, strips.ImageWidth, strips.ImageHeight); + + SendHtml(SnapshotPage.GetShellPage(sessionKey, strips)); + } + + private void HandleBlankStrip(Uri requestUrl) + { + var qs = HttpUtility.ParseQueryString(requestUrl.Query); + string key = qs["key"]; + + if (string.IsNullOrEmpty(key) || !Cache.TryGetValue(key, out var stripSet)) + { + SendHtml("

Session not found.

"); + return; + } + + byte[] gif = stripSet.BlankStripGif; + ClientResponse.StatusCode = 200; + ClientResponse.ContentType = "image/gif"; + ClientResponse.ContentLength64 = gif.Length; + ClientResponse.SendHeaders(); + ClientResponse.OutputStream.Write(gif, 0, gif.Length); + ClientResponse.Close(); + } + + private void HandleStrip(Uri requestUrl) + { + var qs = HttpUtility.ParseQueryString(requestUrl.Query); + string key = qs["key"]; + string iStr = qs["i"]; + + if (string.IsNullOrEmpty(key) || + !int.TryParse(iStr, out int index) || + !Cache.TryGetValue(key, out var stripSet) || + index < 0 || index >= stripSet.Strips.Length) + { + SendHtml("

Strip not found.

"); + return; + } + + byte[] jpeg = stripSet.Strips[index].Jpeg; + ClientResponse.StatusCode = 200; + ClientResponse.ContentType = "image/jpeg"; + ClientResponse.ContentLength64 = jpeg.Length; + ClientResponse.SendHeaders(); + ClientResponse.OutputStream.Write(jpeg, 0, jpeg.Length); + ClientResponse.Close(); + } + + private void HandleClick(Uri requestUrl) + { + ClickHandler.Handle(requestUrl, ClientRequest, ClientResponse, Log, Cache); + } + + private void HandleScrollPos(Uri requestUrl) + { + ScrollHandler.HandleScrollPos(requestUrl, ClientResponse, Log, Cache); + } + +private void SendHtml(string html) + { + byte[] buffer = Encoding.UTF8.GetBytes(html); + ClientResponse.StatusCode = 200; + ClientResponse.ContentType = "text/html"; + ClientResponse.ContentLength64 = buffer.Length; + ClientResponse.SendHeaders(); + ClientResponse.OutputStream.Write(buffer, 0, buffer.Length); + ClientResponse.Close(); + } + + private static string GetSessionKey(string url, int width) + { + string raw = url + ":" + width; + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw)); + return Convert.ToHexString(hash); + } + } +} diff --git a/web-snapshot-viewer/test/scroll-test.html b/web-snapshot-viewer/test/scroll-test.html new file mode 100644 index 0000000..2d51a9d --- /dev/null +++ b/web-snapshot-viewer/test/scroll-test.html @@ -0,0 +1,68 @@ + +Scroll Test + +
+y = 0 +
+
+Times of scroll event triggered = 0 +
+
+Press me to get the y position manually +
+
 
+ + + + + \ No newline at end of file diff --git a/web-snapshot-viewer/test/scroll-test.txt b/web-snapshot-viewer/test/scroll-test.txt new file mode 100644 index 0000000..401523c --- /dev/null +++ b/web-snapshot-viewer/test/scroll-test.txt @@ -0,0 +1,6 @@ +Testing: + +[√] On click +[√] Web view position +[x] OnScroll Event +[√] Updating using interval \ No newline at end of file